1 # SPDX-License-Identifier: GPL-2.0-or-later
6 Simple add-on for copying world-space transforms.
8 It's called "global" to avoid confusion with the Blender World data-block.
12 "name": "Copy Global Transform",
13 "author": "Sybren A. Stüvel",
16 "location": "N-panel in the 3D Viewport",
17 "category": "Animation",
18 "support": 'OFFICIAL',
19 "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/copy_global_transform.html",
20 "tracker_url": "https://projects.blender.org/blender/blender-addons/issues",
24 from typing
import Iterable
, Optional
, Union
, Any
27 from bpy
.types
import Context
, Object
, Operator
, Panel
, PoseBone
, UILayout
28 from mathutils
import Matrix
39 """Auto-keying support.
41 Based on Rigify code by Alexander Gavrilov.
45 def keying_options(cls
, context
: Context
) -> set[str]:
46 """Retrieve the general keyframing options from user preferences."""
48 prefs
= context
.preferences
49 ts
= context
.scene
.tool_settings
52 if prefs
.edit
.use_visual_keying
:
53 options
.add('INSERTKEY_VISUAL')
54 if prefs
.edit
.use_keyframe_insert_needed
:
55 options
.add('INSERTKEY_NEEDED')
56 if prefs
.edit
.use_insertkey_xyz_to_rgb
:
57 options
.add('INSERTKEY_XYZ_TO_RGB')
58 if ts
.use_keyframe_cycle_aware
:
59 options
.add('INSERTKEY_CYCLE_AWARE')
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
:
71 if ts
.use_keyframe_insert_keyingset
:
72 # No support for keying sets (yet).
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')
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
]
90 return [all(bone
.lock_rotation
)] * 4
93 def keyframe_channels(
94 target
: Union
[Object
, PoseBone
],
98 locks
: Iterable
[bool],
104 target
.keyframe_insert(data_path
, group
=group
, options
=options
)
107 for index
, lock
in enumerate(locks
):
110 target
.keyframe_insert(data_path
, index
=index
, group
=group
, options
=options
)
113 def key_transformation(
115 target
: Union
[Object
, PoseBone
],
118 """Keyframe transformation properties, avoiding keying locked channels."""
120 is_bone
= isinstance(target
, PoseBone
)
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
))
137 keyframe("rotation_euler", target
.lock_rotation
)
139 keyframe("scale", target
.lock_scale
)
142 def autokey_transformation(cls
, context
: Context
, target
: Union
[Object
, PoseBone
]) -> None:
143 """Auto-key transformation properties."""
145 options
= cls
.autokeying_options(context
)
148 cls
.key_transformation(target
, options
)
151 def get_matrix(context
: Context
) -> Matrix
:
152 bone
= context
.active_pose_bone
154 # Convert matrix to world space
155 arm
= context
.active_object
156 mat
= arm
.matrix_world
@ bone
.matrix
158 mat
= context
.active_object
.matrix_world
163 def set_matrix(context
: Context
, mat
: Matrix
) -> None:
164 bone
= context
.active_pose_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
)
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
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
214 for fcurve
in action
.fcurves
:
215 if not fcurve
.data_path
.startswith(rna_path_prefix
):
218 for kp
in fcurve
.keyframe_points
:
219 if not kp
.select_control_point
:
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"
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'}
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))"
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"
254 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
256 bl_options
= {'REGISTER', 'UNDO'}
262 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
267 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
272 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
275 method
: bpy
.props
.EnumProperty( # type: ignore
278 description
="Update the current transform, selected keyframes, or even create new keys",
280 bake_step
: bpy
.props
.IntProperty( # type: ignore
282 description
="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
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",
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",
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",
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")
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")
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()
330 floats
= tuple(tuple(float(item
) for item
in line
.split()) for line
in lines
)
331 return Matrix(floats
)
334 def parse_repr_m4(value
: str) -> Optional
[Matrix
]:
335 """Four lines of (a, b, c, d) floats."""
337 lines
= value
.strip().splitlines()
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])
351 mat
= self
.parse_print_m4(clipboard
)
354 self
.report({'ERROR'}, "Clipboard does not contain a valid matrix")
358 mat
= self
._maybe
_mirror
(context
, mat
)
359 except UnableToMirrorError
:
360 self
.report({'ERROR'}, "Unable to mirror, no mirror object/bone configured")
364 'CURRENT': self
._paste
_current
,
365 'EXISTING_KEYS': self
._paste
_existing
_keys
,
366 'BAKE': self
._paste
_bake
,
368 return applicator(context
, mat
)
370 def _maybe_mirror(self
, context
: Context
, matrix
: Matrix
) -> Matrix
:
371 if not self
.use_mirror
:
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':
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
:
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.
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.
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
433 def _paste_current(context
: Context
, matrix
: Matrix
) -> set[str]:
434 set_matrix(context
, matrix
)
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")
442 frame_numbers
= _selected_keyframes(context
)
443 if not frame_numbers
:
444 self
.report({'WARNING'}, "No selected frames found")
447 self
._paste
_on
_frames
(context
, frame_numbers
, matrix
)
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")
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
)
464 def _determine_bake_range(self
, context
: Context
) -> tuple[float, float]:
465 frame_numbers
= _selected_keyframes(context
)
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
480 for frame
in frame_numbers
:
481 context
.scene
.frame_set(int(frame
), subframe
=frame
% 1.0)
482 set_matrix(context
, matrix
)
484 context
.scene
.frame_set(int(current_frame
), subframe
=current_frame
% 1.0)
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:
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
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",
522 ).method
= 'EXISTING_KEYS'
523 wants_autokey_col
.operator(
524 "object.paste_transform",
525 text
="Paste and 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:
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)
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'
555 "addon_copy_global_transform_mirror_bone",
557 "edit_bones" if armature_ob
.mode
== 'EDIT' else "bones",
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
:
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"),
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()
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(
626 description
="Bone to use for the mirroring",
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