Import images: add file handler
[blender-addons.git] / copy_global_transform.py
blobba8f44cf975409aaa26da92e913772436e9f138b
1 # SPDX-FileCopyrightText: 2021-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Copy Global Transform
8 Simple add-on for copying world-space transforms.
10 It's called "global" to avoid confusion with the Blender World data-block.
11 """
13 bl_info = {
14 "name": "Copy Global Transform",
15 "author": "Sybren A. Stüvel",
16 "version": (2, 1),
17 "blender": (3, 5, 0),
18 "location": "N-panel in the 3D Viewport",
19 "category": "Animation",
20 "support": 'OFFICIAL',
21 "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/copy_global_transform.html",
22 "tracker_url": "https://projects.blender.org/blender/blender-addons/issues",
25 import ast
26 from typing import Iterable, Optional, Union, Any
28 import bpy
29 from bpy.types import Context, Object, Operator, Panel, PoseBone, UILayout
30 from mathutils import Matrix
33 _axis_enum_items = [
34 ("x", "X", "", 1),
35 ("y", "Y", "", 2),
36 ("z", "Z", "", 3),
40 class AutoKeying:
41 """Auto-keying support.
43 Based on Rigify code by Alexander Gavrilov.
44 """
46 @classmethod
47 def keying_options(cls, context: Context) -> set[str]:
48 """Retrieve the general keyframing options from user preferences."""
50 prefs = context.preferences
51 ts = context.scene.tool_settings
52 options = set()
54 if prefs.edit.use_visual_keying:
55 options.add('INSERTKEY_VISUAL')
56 if prefs.edit.use_keyframe_insert_needed:
57 options.add('INSERTKEY_NEEDED')
58 if ts.use_keyframe_cycle_aware:
59 options.add('INSERTKEY_CYCLE_AWARE')
60 return options
62 @classmethod
63 def autokeying_options(cls, context: Context) -> Optional[set[str]]:
64 """Retrieve the Auto Keyframe options, or None if disabled."""
66 ts = context.scene.tool_settings
68 if not ts.use_keyframe_insert_auto:
69 return None
71 if ts.use_keyframe_insert_keyingset:
72 # No support for keying sets (yet).
73 return None
75 prefs = context.preferences
76 options = cls.keying_options(context)
78 if prefs.edit.use_keyframe_insert_available:
79 options.add('INSERTKEY_AVAILABLE')
80 if ts.auto_keying_mode == 'REPLACE_KEYS':
81 options.add('INSERTKEY_REPLACE')
82 return options
84 @staticmethod
85 def get_4d_rotlock(bone: PoseBone) -> Iterable[bool]:
86 "Retrieve the lock status for 4D rotation."
87 if bone.lock_rotations_4d:
88 return [bone.lock_rotation_w, *bone.lock_rotation]
89 else:
90 return [all(bone.lock_rotation)] * 4
92 @staticmethod
93 def keyframe_channels(
94 target: Union[Object, PoseBone],
95 options: set[str],
96 data_path: str,
97 group: str,
98 locks: Iterable[bool],
99 ) -> None:
100 if all(locks):
101 return
103 if not any(locks):
104 target.keyframe_insert(data_path, group=group, options=options)
105 return
107 for index, lock in enumerate(locks):
108 if lock:
109 continue
110 target.keyframe_insert(data_path, index=index, group=group, options=options)
112 @classmethod
113 def key_transformation(
114 cls,
115 target: Union[Object, PoseBone],
116 options: set[str],
117 ) -> None:
118 """Keyframe transformation properties, avoiding keying locked channels."""
120 is_bone = isinstance(target, PoseBone)
121 if is_bone:
122 group = target.name
123 else:
124 group = "Object Transforms"
126 def keyframe(data_path: str, locks: Iterable[bool]) -> None:
127 cls.keyframe_channels(target, options, data_path, group, locks)
129 if not (is_bone and target.bone.use_connect):
130 keyframe("location", target.lock_location)
132 if target.rotation_mode == 'QUATERNION':
133 keyframe("rotation_quaternion", cls.get_4d_rotlock(target))
134 elif target.rotation_mode == 'AXIS_ANGLE':
135 keyframe("rotation_axis_angle", cls.get_4d_rotlock(target))
136 else:
137 keyframe("rotation_euler", target.lock_rotation)
139 keyframe("scale", target.lock_scale)
141 @classmethod
142 def autokey_transformation(cls, context: Context, target: Union[Object, PoseBone]) -> None:
143 """Auto-key transformation properties."""
145 options = cls.autokeying_options(context)
146 if options is None:
147 return
148 cls.key_transformation(target, options)
151 def get_matrix(context: Context) -> Matrix:
152 bone = context.active_pose_bone
153 if bone:
154 # Convert matrix to world space
155 arm = context.active_object
156 mat = arm.matrix_world @ bone.matrix
157 else:
158 mat = context.active_object.matrix_world
160 return mat
163 def set_matrix(context: Context, mat: Matrix) -> None:
164 bone = context.active_pose_bone
165 if bone:
166 # Convert matrix to local space
167 arm_eval = context.active_object.evaluated_get(context.view_layer.depsgraph)
168 bone.matrix = arm_eval.matrix_world.inverted() @ mat
169 AutoKeying.autokey_transformation(context, bone)
170 else:
171 context.active_object.matrix_world = mat
172 AutoKeying.autokey_transformation(context, context.active_object)
175 def _selected_keyframes(context: Context) -> list[float]:
176 """Return the list of frame numbers that have a selected key.
178 Only keys on the active bone/object are considered.
180 bone = context.active_pose_bone
181 if bone:
182 return _selected_keyframes_for_bone(context.active_object, bone)
183 return _selected_keyframes_for_object(context.active_object)
186 def _selected_keyframes_for_bone(object: Object, bone: PoseBone) -> list[float]:
187 """Return the list of frame numbers that have a selected key.
189 Only keys on the given pose bone are considered.
191 name = bpy.utils.escape_identifier(bone.name)
192 return _selected_keyframes_in_action(object, f'pose.bones["{name}"].')
195 def _selected_keyframes_for_object(object: Object) -> list[float]:
196 """Return the list of frame numbers that have a selected key.
198 Only keys on the given object are considered.
200 return _selected_keyframes_in_action(object, "")
203 def _selected_keyframes_in_action(object: Object, rna_path_prefix: str) -> list[float]:
204 """Return the list of frame numbers that have a selected key.
206 Only keys on the given object's Action on FCurves starting with rna_path_prefix are considered.
209 action = object.animation_data and object.animation_data.action
210 if action is None:
211 return []
213 keyframes = set()
214 for fcurve in action.fcurves:
215 if not fcurve.data_path.startswith(rna_path_prefix):
216 continue
218 for kp in fcurve.keyframe_points:
219 if not kp.select_control_point:
220 continue
221 keyframes.add(kp.co.x)
222 return sorted(keyframes)
225 class OBJECT_OT_copy_global_transform(Operator):
226 bl_idname = "object.copy_global_transform"
227 bl_label = "Copy Global Transform"
228 bl_description = (
229 "Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
231 # This operator cannot be un-done because it manipulates data outside Blender.
232 bl_options = {'REGISTER'}
234 @classmethod
235 def poll(cls, context: Context) -> bool:
236 return bool(context.active_pose_bone) or bool(context.active_object)
238 def execute(self, context: Context) -> set[str]:
239 mat = get_matrix(context)
240 rows = [f" {tuple(row)!r}," for row in mat]
241 as_string = "\n".join(rows)
242 context.window_manager.clipboard = f"Matrix((\n{as_string}\n))"
243 return {'FINISHED'}
246 class UnableToMirrorError(Exception):
247 """Raised when mirroring is enabled but no mirror object/bone is set."""
250 class OBJECT_OT_paste_transform(Operator):
251 bl_idname = "object.paste_transform"
252 bl_label = "Paste Global Transform"
253 bl_description = (
254 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
256 bl_options = {'REGISTER', 'UNDO'}
258 _method_items = [
260 'CURRENT',
261 "Current Transform",
262 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
265 'EXISTING_KEYS',
266 "Selected Keys",
267 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
270 'BAKE',
271 "Bake on Key Range",
272 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
275 method: bpy.props.EnumProperty( # type: ignore
276 items=_method_items,
277 name="Paste Method",
278 description="Update the current transform, selected keyframes, or even create new keys",
280 bake_step: bpy.props.IntProperty( # type: ignore
281 name="Frame Step",
282 description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
283 min=1,
284 soft_min=1,
285 soft_max=5,
288 use_mirror: bpy.props.BoolProperty( # type: ignore
289 name="Mirror Transform",
290 description="When pasting, mirror the transform relative to a specific object or bone",
291 default=False,
294 mirror_axis_loc: bpy.props.EnumProperty( # type: ignore
295 items=_axis_enum_items,
296 name="Location Axis",
297 description="Coordinate axis used to mirror the location part of the transform",
298 default='x',
300 mirror_axis_rot: bpy.props.EnumProperty( # type: ignore
301 items=_axis_enum_items,
302 name="Rotation Axis",
303 description="Coordinate axis used to mirror the rotation part of the transform",
304 default='z',
307 @classmethod
308 def poll(cls, context: Context) -> bool:
309 if not context.active_pose_bone and not context.active_object:
310 cls.poll_message_set("Select an object or pose bone")
311 return False
313 clipboard = context.window_manager.clipboard.strip()
314 if not (clipboard.startswith("Matrix(") or clipboard.startswith("<Matrix 4x4")):
315 cls.poll_message_set("Clipboard does not contain a valid matrix")
316 return False
317 return True
319 @staticmethod
320 def parse_print_m4(value: str) -> Optional[Matrix]:
321 """Parse output from Blender's print_m4() function.
323 Expects four lines of space-separated floats.
326 lines = value.strip().splitlines()
327 if len(lines) != 4:
328 return None
330 floats = tuple(tuple(float(item) for item in line.split()) for line in lines)
331 return Matrix(floats)
333 @staticmethod
334 def parse_repr_m4(value: str) -> Optional[Matrix]:
335 """Four lines of (a, b, c, d) floats."""
337 lines = value.strip().splitlines()
338 if len(lines) != 4:
339 return None
341 floats = tuple(tuple(float(item.strip()) for item in line.strip()[1:-1].split(',')) for line in lines)
342 return Matrix(floats)
344 def execute(self, context: Context) -> set[str]:
345 clipboard = context.window_manager.clipboard.strip()
346 if clipboard.startswith("Matrix"):
347 mat = Matrix(ast.literal_eval(clipboard[6:]))
348 elif clipboard.startswith("<Matrix 4x4"):
349 mat = self.parse_repr_m4(clipboard[12:-1])
350 else:
351 mat = self.parse_print_m4(clipboard)
353 if mat is None:
354 self.report({'ERROR'}, "Clipboard does not contain a valid matrix")
355 return {'CANCELLED'}
357 try:
358 mat = self._maybe_mirror(context, mat)
359 except UnableToMirrorError:
360 self.report({'ERROR'}, "Unable to mirror, no mirror object/bone configured")
361 return {'CANCELLED'}
363 applicator = {
364 'CURRENT': self._paste_current,
365 'EXISTING_KEYS': self._paste_existing_keys,
366 'BAKE': self._paste_bake,
367 }[self.method]
368 return applicator(context, mat)
370 def _maybe_mirror(self, context: Context, matrix: Matrix) -> Matrix:
371 if not self.use_mirror:
372 return matrix
374 mirror_ob = context.scene.addon_copy_global_transform_mirror_ob
375 mirror_bone = context.scene.addon_copy_global_transform_mirror_bone
377 # No mirror object means "current armature object".
378 ctx_ob = context.object
379 if not mirror_ob and mirror_bone and ctx_ob and ctx_ob.type == 'ARMATURE':
380 mirror_ob = ctx_ob
382 if not mirror_ob:
383 raise UnableToMirrorError()
385 if mirror_ob.type == 'ARMATURE' and mirror_bone:
386 return self._mirror_over_bone(matrix, mirror_ob, mirror_bone)
387 return self._mirror_over_ob(matrix, mirror_ob)
389 def _mirror_over_ob(self, matrix: Matrix, mirror_ob: bpy.types.Object) -> Matrix:
390 mirror_matrix = mirror_ob.matrix_world
391 return self._mirror_over_matrix(matrix, mirror_matrix)
393 def _mirror_over_bone(self, matrix: Matrix, mirror_ob: bpy.types.Object, mirror_bone_name: str) -> Matrix:
394 bone = mirror_ob.pose.bones[mirror_bone_name]
395 mirror_matrix = mirror_ob.matrix_world @ bone.matrix
396 return self._mirror_over_matrix(matrix, mirror_matrix)
398 def _mirror_over_matrix(self, matrix: Matrix, mirror_matrix: Matrix) -> Matrix:
399 # Compute the matrix in the space of the mirror matrix:
400 mat_local = mirror_matrix.inverted() @ matrix
402 # Decompose the matrix, as we don't want to touch the scale. This
403 # operator should only mirror the translation and rotation components.
404 trans, rot_q, scale = mat_local.decompose()
406 # Mirror the translation component:
407 axis_index = ord(self.mirror_axis_loc) - ord('x')
408 trans[axis_index] *= -1
410 # Flip the rotation, and use a rotation order that applies the to-be-flipped axes first.
411 match self.mirror_axis_rot:
412 case 'x':
413 rot_e = rot_q.to_euler('XYZ')
414 rot_e.x *= -1 # Flip the requested rotation axis.
415 rot_e.y *= -1 # Also flip the bone roll.
416 case 'y':
417 rot_e = rot_q.to_euler('YZX')
418 rot_e.y *= -1 # Flip the requested rotation axis.
419 rot_e.z *= -1 # Also flip another axis? Not sure how to handle this one.
420 case 'z':
421 rot_e = rot_q.to_euler('ZYX')
422 rot_e.z *= -1 # Flip the requested rotation axis.
423 rot_e.y *= -1 # Also flip the bone roll.
425 # Recompose the local matrix:
426 mat_local = Matrix.LocRotScale(trans, rot_e, scale)
428 # Go back to world space:
429 mirrored_world = mirror_matrix @ mat_local
430 return mirrored_world
432 @staticmethod
433 def _paste_current(context: Context, matrix: Matrix) -> set[str]:
434 set_matrix(context, matrix)
435 return {'FINISHED'}
437 def _paste_existing_keys(self, context: Context, matrix: Matrix) -> set[str]:
438 if not context.scene.tool_settings.use_keyframe_insert_auto:
439 self.report({'ERROR'}, "This mode requires auto-keying to work properly")
440 return {'CANCELLED'}
442 frame_numbers = _selected_keyframes(context)
443 if not frame_numbers:
444 self.report({'WARNING'}, "No selected frames found")
445 return {'CANCELLED'}
447 self._paste_on_frames(context, frame_numbers, matrix)
448 return {'FINISHED'}
450 def _paste_bake(self, context: Context, matrix: Matrix) -> set[str]:
451 if not context.scene.tool_settings.use_keyframe_insert_auto:
452 self.report({'ERROR'}, "This mode requires auto-keying to work properly")
453 return {'CANCELLED'}
455 bake_step = max(1, self.bake_step)
456 # Put the clamped bake step back into RNA for the redo panel.
457 self.bake_step = bake_step
459 frame_start, frame_end = self._determine_bake_range(context)
460 frame_range = range(round(frame_start), round(frame_end) + bake_step, bake_step)
461 self._paste_on_frames(context, frame_range, matrix)
462 return {'FINISHED'}
464 def _determine_bake_range(self, context: Context) -> tuple[float, float]:
465 frame_numbers = _selected_keyframes(context)
466 if frame_numbers:
467 # Note that these could be the same frame, if len(frame_numbers) == 1:
468 return frame_numbers[0], frame_numbers[-1]
470 if context.scene.use_preview_range:
471 self.report({'INFO'}, "No selected keys, pasting over preview range")
472 return context.scene.frame_preview_start, context.scene.frame_preview_end
474 self.report({'INFO'}, "No selected keys, pasting over scene range")
475 return context.scene.frame_start, context.scene.frame_end
477 def _paste_on_frames(self, context: Context, frame_numbers: Iterable[float], matrix: Matrix) -> None:
478 current_frame = context.scene.frame_current_final
479 try:
480 for frame in frame_numbers:
481 context.scene.frame_set(int(frame), subframe=frame % 1.0)
482 set_matrix(context, matrix)
483 finally:
484 context.scene.frame_set(int(current_frame), subframe=current_frame % 1.0)
487 class PanelMixin:
488 bl_space_type = 'VIEW_3D'
489 bl_region_type = 'UI'
490 bl_category = "Animation"
493 class VIEW3D_PT_copy_global_transform(PanelMixin, Panel):
494 bl_label = "Global Transform"
496 def draw(self, context: Context) -> None:
497 layout = self.layout
499 # No need to put "Global Transform" in the operator text, given that it's already in the panel title.
500 layout.operator("object.copy_global_transform", text="Copy", icon='COPYDOWN')
502 paste_col = layout.column(align=True)
504 paste_row = paste_col.row(align=True)
505 paste_props = paste_row.operator("object.paste_transform", text="Paste", icon='PASTEDOWN')
506 paste_props.method = 'CURRENT'
507 paste_props.use_mirror = False
508 paste_props = paste_row.operator("object.paste_transform", text="Mirrored", icon='PASTEFLIPDOWN')
509 paste_props.method = 'CURRENT'
510 paste_props.use_mirror = True
512 wants_autokey_col = paste_col.column(align=True)
513 has_autokey = context.scene.tool_settings.use_keyframe_insert_auto
514 wants_autokey_col.enabled = has_autokey
515 if not has_autokey:
516 wants_autokey_col.label(text="These require auto-key:")
518 wants_autokey_col.operator(
519 "object.paste_transform",
520 text="Paste to Selected Keys",
521 icon='PASTEDOWN',
522 ).method = 'EXISTING_KEYS'
523 wants_autokey_col.operator(
524 "object.paste_transform",
525 text="Paste and Bake",
526 icon='PASTEDOWN',
527 ).method = 'BAKE'
530 class VIEW3D_PT_copy_global_transform_mirror(PanelMixin, Panel):
531 bl_label = "Mirror Options"
532 bl_parent_id = "VIEW3D_PT_copy_global_transform"
534 def draw(self, context: Context) -> None:
535 layout = self.layout
536 scene = context.scene
537 layout.prop(scene, 'addon_copy_global_transform_mirror_ob', text="Object")
539 mirror_ob = scene.addon_copy_global_transform_mirror_ob
540 if mirror_ob is None:
541 # No explicit mirror object means "the current armature", so then the bone name should be editable.
542 if context.object and context.object.type == 'ARMATURE':
543 self._bone_search(layout, scene, context.object)
544 else:
545 self._bone_entry(layout, scene)
546 elif mirror_ob.type == 'ARMATURE':
547 self._bone_search(layout, scene, mirror_ob)
549 def _bone_search(self, layout: UILayout, scene: bpy.types.Scene, armature_ob: bpy.types.Object) -> None:
550 """Search within the bones of the given armature."""
551 assert armature_ob and armature_ob.type == 'ARMATURE'
553 layout.prop_search(
554 scene,
555 "addon_copy_global_transform_mirror_bone",
556 armature_ob.data,
557 "edit_bones" if armature_ob.mode == 'EDIT' else "bones",
558 text="Bone",
561 def _bone_entry(self, layout: UILayout, scene: bpy.types.Scene) -> None:
562 """Allow manual entry of a bone name."""
563 layout.prop(scene, "addon_copy_global_transform_mirror_bone", text="Bone")
566 ### Messagebus subscription to monitor changes & refresh panels.
567 _msgbus_owner = object()
570 def _refresh_3d_panels():
571 refresh_area_types = {'VIEW_3D'}
572 for win in bpy.context.window_manager.windows:
573 for area in win.screen.areas:
574 if area.type not in refresh_area_types:
575 continue
576 area.tag_redraw()
579 classes = (
580 OBJECT_OT_copy_global_transform,
581 OBJECT_OT_paste_transform,
582 VIEW3D_PT_copy_global_transform,
583 VIEW3D_PT_copy_global_transform_mirror,
585 _register, _unregister = bpy.utils.register_classes_factory(classes)
588 def _register_message_bus() -> None:
589 bpy.msgbus.subscribe_rna(
590 key=(bpy.types.ToolSettings, "use_keyframe_insert_auto"),
591 owner=_msgbus_owner,
592 args=(),
593 notify=_refresh_3d_panels,
594 options={'PERSISTENT'},
598 def _unregister_message_bus() -> None:
599 bpy.msgbus.clear_by_owner(_msgbus_owner)
602 @bpy.app.handlers.persistent # type: ignore
603 def _on_blendfile_load_post(none: Any, other_none: Any) -> None:
604 # The parameters are required, but both are None.
605 _register_message_bus()
608 def register():
609 _register()
610 bpy.app.handlers.load_post.append(_on_blendfile_load_post)
612 # The mirror object & bone name are stored on the scene, and not on the
613 # operator. This makes it possible to set up the operator for use in a
614 # certain scene, while keeping hotkey assignments working as usual.
616 # The goal is to allow hotkeys for "copy", "paste", and "paste mirrored",
617 # while keeping the other choices in a more global place.
618 bpy.types.Scene.addon_copy_global_transform_mirror_ob = bpy.props.PointerProperty(
619 type=bpy.types.Object,
620 name="Mirror Object",
621 description="Object to mirror over. Leave empty and name a bone to always mirror "
622 "over that bone of the active armature",
624 bpy.types.Scene.addon_copy_global_transform_mirror_bone = bpy.props.StringProperty(
625 name="Mirror Bone",
626 description="Bone to use for the mirroring",
630 def unregister():
631 _unregister()
632 _unregister_message_bus()
633 bpy.app.handlers.load_post.remove(_on_blendfile_load_post)
635 del bpy.types.Scene.addon_copy_global_transform_mirror_ob
636 del bpy.types.Scene.addon_copy_global_transform_mirror_bone