1 # SPDX-FileCopyrightText: 2021-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
8 Simple add-on for copying world-space transforms.
10 It's called "global" to avoid confusion with the Blender World data-block.
14 "name": "Copy Global Transform",
15 "author": "Sybren A. Stüvel",
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",
26 from typing
import Iterable
, Optional
, Union
, Any
29 from bpy
.types
import Context
, Object
, Operator
, Panel
, PoseBone
, UILayout
30 from mathutils
import Matrix
41 """Auto-keying support.
43 Based on Rigify code by Alexander Gavrilov.
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
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 prefs
.edit
.use_insertkey_xyz_to_rgb
:
59 options
.add('INSERTKEY_XYZ_TO_RGB')
60 if ts
.use_keyframe_cycle_aware
:
61 options
.add('INSERTKEY_CYCLE_AWARE')
65 def autokeying_options(cls
, context
: Context
) -> Optional
[set[str]]:
66 """Retrieve the Auto Keyframe options, or None if disabled."""
68 ts
= context
.scene
.tool_settings
70 if not ts
.use_keyframe_insert_auto
:
73 if ts
.use_keyframe_insert_keyingset
:
74 # No support for keying sets (yet).
77 prefs
= context
.preferences
78 options
= cls
.keying_options(context
)
80 if prefs
.edit
.use_keyframe_insert_available
:
81 options
.add('INSERTKEY_AVAILABLE')
82 if ts
.auto_keying_mode
== 'REPLACE_KEYS':
83 options
.add('INSERTKEY_REPLACE')
87 def get_4d_rotlock(bone
: PoseBone
) -> Iterable
[bool]:
88 "Retrieve the lock status for 4D rotation."
89 if bone
.lock_rotations_4d
:
90 return [bone
.lock_rotation_w
, *bone
.lock_rotation
]
92 return [all(bone
.lock_rotation
)] * 4
95 def keyframe_channels(
96 target
: Union
[Object
, PoseBone
],
100 locks
: Iterable
[bool],
106 target
.keyframe_insert(data_path
, group
=group
, options
=options
)
109 for index
, lock
in enumerate(locks
):
112 target
.keyframe_insert(data_path
, index
=index
, group
=group
, options
=options
)
115 def key_transformation(
117 target
: Union
[Object
, PoseBone
],
120 """Keyframe transformation properties, avoiding keying locked channels."""
122 is_bone
= isinstance(target
, PoseBone
)
126 group
= "Object Transforms"
128 def keyframe(data_path
: str, locks
: Iterable
[bool]) -> None:
129 cls
.keyframe_channels(target
, options
, data_path
, group
, locks
)
131 if not (is_bone
and target
.bone
.use_connect
):
132 keyframe("location", target
.lock_location
)
134 if target
.rotation_mode
== 'QUATERNION':
135 keyframe("rotation_quaternion", cls
.get_4d_rotlock(target
))
136 elif target
.rotation_mode
== 'AXIS_ANGLE':
137 keyframe("rotation_axis_angle", cls
.get_4d_rotlock(target
))
139 keyframe("rotation_euler", target
.lock_rotation
)
141 keyframe("scale", target
.lock_scale
)
144 def autokey_transformation(cls
, context
: Context
, target
: Union
[Object
, PoseBone
]) -> None:
145 """Auto-key transformation properties."""
147 options
= cls
.autokeying_options(context
)
150 cls
.key_transformation(target
, options
)
153 def get_matrix(context
: Context
) -> Matrix
:
154 bone
= context
.active_pose_bone
156 # Convert matrix to world space
157 arm
= context
.active_object
158 mat
= arm
.matrix_world
@ bone
.matrix
160 mat
= context
.active_object
.matrix_world
165 def set_matrix(context
: Context
, mat
: Matrix
) -> None:
166 bone
= context
.active_pose_bone
168 # Convert matrix to local space
169 arm_eval
= context
.active_object
.evaluated_get(context
.view_layer
.depsgraph
)
170 bone
.matrix
= arm_eval
.matrix_world
.inverted() @ mat
171 AutoKeying
.autokey_transformation(context
, bone
)
173 context
.active_object
.matrix_world
= mat
174 AutoKeying
.autokey_transformation(context
, context
.active_object
)
177 def _selected_keyframes(context
: Context
) -> list[float]:
178 """Return the list of frame numbers that have a selected key.
180 Only keys on the active bone/object are considered.
182 bone
= context
.active_pose_bone
184 return _selected_keyframes_for_bone(context
.active_object
, bone
)
185 return _selected_keyframes_for_object(context
.active_object
)
188 def _selected_keyframes_for_bone(object: Object
, bone
: PoseBone
) -> list[float]:
189 """Return the list of frame numbers that have a selected key.
191 Only keys on the given pose bone are considered.
193 name
= bpy
.utils
.escape_identifier(bone
.name
)
194 return _selected_keyframes_in_action(object, f
'pose.bones["{name}"].')
197 def _selected_keyframes_for_object(object: Object
) -> list[float]:
198 """Return the list of frame numbers that have a selected key.
200 Only keys on the given object are considered.
202 return _selected_keyframes_in_action(object, "")
205 def _selected_keyframes_in_action(object: Object
, rna_path_prefix
: str) -> list[float]:
206 """Return the list of frame numbers that have a selected key.
208 Only keys on the given object's Action on FCurves starting with rna_path_prefix are considered.
211 action
= object.animation_data
and object.animation_data
.action
216 for fcurve
in action
.fcurves
:
217 if not fcurve
.data_path
.startswith(rna_path_prefix
):
220 for kp
in fcurve
.keyframe_points
:
221 if not kp
.select_control_point
:
223 keyframes
.add(kp
.co
.x
)
224 return sorted(keyframes
)
227 class OBJECT_OT_copy_global_transform(Operator
):
228 bl_idname
= "object.copy_global_transform"
229 bl_label
= "Copy Global Transform"
231 "Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
233 # This operator cannot be un-done because it manipulates data outside Blender.
234 bl_options
= {'REGISTER'}
237 def poll(cls
, context
: Context
) -> bool:
238 return bool(context
.active_pose_bone
) or bool(context
.active_object
)
240 def execute(self
, context
: Context
) -> set[str]:
241 mat
= get_matrix(context
)
242 rows
= [f
" {tuple(row)!r}," for row
in mat
]
243 as_string
= "\n".join(rows
)
244 context
.window_manager
.clipboard
= f
"Matrix((\n{as_string}\n))"
248 class UnableToMirrorError(Exception):
249 """Raised when mirroring is enabled but no mirror object/bone is set."""
252 class OBJECT_OT_paste_transform(Operator
):
253 bl_idname
= "object.paste_transform"
254 bl_label
= "Paste Global Transform"
256 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
258 bl_options
= {'REGISTER', 'UNDO'}
264 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
269 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
274 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
277 method
: bpy
.props
.EnumProperty( # type: ignore
280 description
="Update the current transform, selected keyframes, or even create new keys",
282 bake_step
: bpy
.props
.IntProperty( # type: ignore
284 description
="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
290 use_mirror
: bpy
.props
.BoolProperty( # type: ignore
291 name
="Mirror Transform",
292 description
="When pasting, mirror the transform relative to a specific object or bone",
296 mirror_axis_loc
: bpy
.props
.EnumProperty( # type: ignore
297 items
=_axis_enum_items
,
298 name
="Location Axis",
299 description
="Coordinate axis used to mirror the location part of the transform",
302 mirror_axis_rot
: bpy
.props
.EnumProperty( # type: ignore
303 items
=_axis_enum_items
,
304 name
="Rotation Axis",
305 description
="Coordinate axis used to mirror the rotation part of the transform",
310 def poll(cls
, context
: Context
) -> bool:
311 if not context
.active_pose_bone
and not context
.active_object
:
312 cls
.poll_message_set("Select an object or pose bone")
315 clipboard
= context
.window_manager
.clipboard
.strip()
316 if not (clipboard
.startswith("Matrix(") or clipboard
.startswith("<Matrix 4x4")):
317 cls
.poll_message_set("Clipboard does not contain a valid matrix")
322 def parse_print_m4(value
: str) -> Optional
[Matrix
]:
323 """Parse output from Blender's print_m4() function.
325 Expects four lines of space-separated floats.
328 lines
= value
.strip().splitlines()
332 floats
= tuple(tuple(float(item
) for item
in line
.split()) for line
in lines
)
333 return Matrix(floats
)
336 def parse_repr_m4(value
: str) -> Optional
[Matrix
]:
337 """Four lines of (a, b, c, d) floats."""
339 lines
= value
.strip().splitlines()
343 floats
= tuple(tuple(float(item
.strip()) for item
in line
.strip()[1:-1].split(',')) for line
in lines
)
344 return Matrix(floats
)
346 def execute(self
, context
: Context
) -> set[str]:
347 clipboard
= context
.window_manager
.clipboard
.strip()
348 if clipboard
.startswith("Matrix"):
349 mat
= Matrix(ast
.literal_eval(clipboard
[6:]))
350 elif clipboard
.startswith("<Matrix 4x4"):
351 mat
= self
.parse_repr_m4(clipboard
[12:-1])
353 mat
= self
.parse_print_m4(clipboard
)
356 self
.report({'ERROR'}, "Clipboard does not contain a valid matrix")
360 mat
= self
._maybe
_mirror
(context
, mat
)
361 except UnableToMirrorError
:
362 self
.report({'ERROR'}, "Unable to mirror, no mirror object/bone configured")
366 'CURRENT': self
._paste
_current
,
367 'EXISTING_KEYS': self
._paste
_existing
_keys
,
368 'BAKE': self
._paste
_bake
,
370 return applicator(context
, mat
)
372 def _maybe_mirror(self
, context
: Context
, matrix
: Matrix
) -> Matrix
:
373 if not self
.use_mirror
:
376 mirror_ob
= context
.scene
.addon_copy_global_transform_mirror_ob
377 mirror_bone
= context
.scene
.addon_copy_global_transform_mirror_bone
379 # No mirror object means "current armature object".
380 ctx_ob
= context
.object
381 if not mirror_ob
and mirror_bone
and ctx_ob
and ctx_ob
.type == 'ARMATURE':
385 raise UnableToMirrorError()
387 if mirror_ob
.type == 'ARMATURE' and mirror_bone
:
388 return self
._mirror
_over
_bone
(matrix
, mirror_ob
, mirror_bone
)
389 return self
._mirror
_over
_ob
(matrix
, mirror_ob
)
391 def _mirror_over_ob(self
, matrix
: Matrix
, mirror_ob
: bpy
.types
.Object
) -> Matrix
:
392 mirror_matrix
= mirror_ob
.matrix_world
393 return self
._mirror
_over
_matrix
(matrix
, mirror_matrix
)
395 def _mirror_over_bone(self
, matrix
: Matrix
, mirror_ob
: bpy
.types
.Object
, mirror_bone_name
: str) -> Matrix
:
396 bone
= mirror_ob
.pose
.bones
[mirror_bone_name
]
397 mirror_matrix
= mirror_ob
.matrix_world
@ bone
.matrix
398 return self
._mirror
_over
_matrix
(matrix
, mirror_matrix
)
400 def _mirror_over_matrix(self
, matrix
: Matrix
, mirror_matrix
: Matrix
) -> Matrix
:
401 # Compute the matrix in the space of the mirror matrix:
402 mat_local
= mirror_matrix
.inverted() @ matrix
404 # Decompose the matrix, as we don't want to touch the scale. This
405 # operator should only mirror the translation and rotation components.
406 trans
, rot_q
, scale
= mat_local
.decompose()
408 # Mirror the translation component:
409 axis_index
= ord(self
.mirror_axis_loc
) - ord('x')
410 trans
[axis_index
] *= -1
412 # Flip the rotation, and use a rotation order that applies the to-be-flipped axes first.
413 match self
.mirror_axis_rot
:
415 rot_e
= rot_q
.to_euler('XYZ')
416 rot_e
.x
*= -1 # Flip the requested rotation axis.
417 rot_e
.y
*= -1 # Also flip the bone roll.
419 rot_e
= rot_q
.to_euler('YZX')
420 rot_e
.y
*= -1 # Flip the requested rotation axis.
421 rot_e
.z
*= -1 # Also flip another axis? Not sure how to handle this one.
423 rot_e
= rot_q
.to_euler('ZYX')
424 rot_e
.z
*= -1 # Flip the requested rotation axis.
425 rot_e
.y
*= -1 # Also flip the bone roll.
427 # Recompose the local matrix:
428 mat_local
= Matrix
.LocRotScale(trans
, rot_e
, scale
)
430 # Go back to world space:
431 mirrored_world
= mirror_matrix
@ mat_local
432 return mirrored_world
435 def _paste_current(context
: Context
, matrix
: Matrix
) -> set[str]:
436 set_matrix(context
, matrix
)
439 def _paste_existing_keys(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
440 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
441 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
444 frame_numbers
= _selected_keyframes(context
)
445 if not frame_numbers
:
446 self
.report({'WARNING'}, "No selected frames found")
449 self
._paste
_on
_frames
(context
, frame_numbers
, matrix
)
452 def _paste_bake(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
453 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
454 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
457 bake_step
= max(1, self
.bake_step
)
458 # Put the clamped bake step back into RNA for the redo panel.
459 self
.bake_step
= bake_step
461 frame_start
, frame_end
= self
._determine
_bake
_range
(context
)
462 frame_range
= range(round(frame_start
), round(frame_end
) + bake_step
, bake_step
)
463 self
._paste
_on
_frames
(context
, frame_range
, matrix
)
466 def _determine_bake_range(self
, context
: Context
) -> tuple[float, float]:
467 frame_numbers
= _selected_keyframes(context
)
469 # Note that these could be the same frame, if len(frame_numbers) == 1:
470 return frame_numbers
[0], frame_numbers
[-1]
472 if context
.scene
.use_preview_range
:
473 self
.report({'INFO'}, "No selected keys, pasting over preview range")
474 return context
.scene
.frame_preview_start
, context
.scene
.frame_preview_end
476 self
.report({'INFO'}, "No selected keys, pasting over scene range")
477 return context
.scene
.frame_start
, context
.scene
.frame_end
479 def _paste_on_frames(self
, context
: Context
, frame_numbers
: Iterable
[float], matrix
: Matrix
) -> None:
480 current_frame
= context
.scene
.frame_current_final
482 for frame
in frame_numbers
:
483 context
.scene
.frame_set(int(frame
), subframe
=frame
% 1.0)
484 set_matrix(context
, matrix
)
486 context
.scene
.frame_set(int(current_frame
), subframe
=current_frame
% 1.0)
490 bl_space_type
= 'VIEW_3D'
491 bl_region_type
= 'UI'
492 bl_category
= "Animation"
495 class VIEW3D_PT_copy_global_transform(PanelMixin
, Panel
):
496 bl_label
= "Global Transform"
498 def draw(self
, context
: Context
) -> None:
501 # No need to put "Global Transform" in the operator text, given that it's already in the panel title.
502 layout
.operator("object.copy_global_transform", text
="Copy", icon
='COPYDOWN')
504 paste_col
= layout
.column(align
=True)
506 paste_row
= paste_col
.row(align
=True)
507 paste_props
= paste_row
.operator("object.paste_transform", text
="Paste", icon
='PASTEDOWN')
508 paste_props
.method
= 'CURRENT'
509 paste_props
.use_mirror
= False
510 paste_props
= paste_row
.operator("object.paste_transform", text
="Mirrored", icon
='PASTEFLIPDOWN')
511 paste_props
.method
= 'CURRENT'
512 paste_props
.use_mirror
= True
514 wants_autokey_col
= paste_col
.column(align
=True)
515 has_autokey
= context
.scene
.tool_settings
.use_keyframe_insert_auto
516 wants_autokey_col
.enabled
= has_autokey
518 wants_autokey_col
.label(text
="These require auto-key:")
520 wants_autokey_col
.operator(
521 "object.paste_transform",
522 text
="Paste to Selected Keys",
524 ).method
= 'EXISTING_KEYS'
525 wants_autokey_col
.operator(
526 "object.paste_transform",
527 text
="Paste and Bake",
532 class VIEW3D_PT_copy_global_transform_mirror(PanelMixin
, Panel
):
533 bl_label
= "Mirror Options"
534 bl_parent_id
= "VIEW3D_PT_copy_global_transform"
536 def draw(self
, context
: Context
) -> None:
538 scene
= context
.scene
539 layout
.prop(scene
, 'addon_copy_global_transform_mirror_ob', text
="Object")
541 mirror_ob
= scene
.addon_copy_global_transform_mirror_ob
542 if mirror_ob
is None:
543 # No explicit mirror object means "the current armature", so then the bone name should be editable.
544 if context
.object and context
.object.type == 'ARMATURE':
545 self
._bone
_search
(layout
, scene
, context
.object)
547 self
._bone
_entry
(layout
, scene
)
548 elif mirror_ob
.type == 'ARMATURE':
549 self
._bone
_search
(layout
, scene
, mirror_ob
)
551 def _bone_search(self
, layout
: UILayout
, scene
: bpy
.types
.Scene
, armature_ob
: bpy
.types
.Object
) -> None:
552 """Search within the bones of the given armature."""
553 assert armature_ob
and armature_ob
.type == 'ARMATURE'
557 "addon_copy_global_transform_mirror_bone",
559 "edit_bones" if armature_ob
.mode
== 'EDIT' else "bones",
563 def _bone_entry(self
, layout
: UILayout
, scene
: bpy
.types
.Scene
) -> None:
564 """Allow manual entry of a bone name."""
565 layout
.prop(scene
, "addon_copy_global_transform_mirror_bone", text
="Bone")
568 ### Messagebus subscription to monitor changes & refresh panels.
569 _msgbus_owner
= object()
572 def _refresh_3d_panels():
573 refresh_area_types
= {'VIEW_3D'}
574 for win
in bpy
.context
.window_manager
.windows
:
575 for area
in win
.screen
.areas
:
576 if area
.type not in refresh_area_types
:
582 OBJECT_OT_copy_global_transform
,
583 OBJECT_OT_paste_transform
,
584 VIEW3D_PT_copy_global_transform
,
585 VIEW3D_PT_copy_global_transform_mirror
,
587 _register
, _unregister
= bpy
.utils
.register_classes_factory(classes
)
590 def _register_message_bus() -> None:
591 bpy
.msgbus
.subscribe_rna(
592 key
=(bpy
.types
.ToolSettings
, "use_keyframe_insert_auto"),
595 notify
=_refresh_3d_panels
,
596 options
={'PERSISTENT'},
600 def _unregister_message_bus() -> None:
601 bpy
.msgbus
.clear_by_owner(_msgbus_owner
)
604 @bpy.app
.handlers
.persistent
# type: ignore
605 def _on_blendfile_load_post(none
: Any
, other_none
: Any
) -> None:
606 # The parameters are required, but both are None.
607 _register_message_bus()
612 bpy
.app
.handlers
.load_post
.append(_on_blendfile_load_post
)
614 # The mirror object & bone name are stored on the scene, and not on the
615 # operator. This makes it possible to set up the operator for use in a
616 # certain scene, while keeping hotkey assignments working as usual.
618 # The goal is to allow hotkeys for "copy", "paste", and "paste mirrored",
619 # while keeping the other choices in a more global place.
620 bpy
.types
.Scene
.addon_copy_global_transform_mirror_ob
= bpy
.props
.PointerProperty(
621 type=bpy
.types
.Object
,
622 name
="Mirror Object",
623 description
="Object to mirror over. Leave empty and name a bone to always mirror "
624 "over that bone of the active armature",
626 bpy
.types
.Scene
.addon_copy_global_transform_mirror_bone
= bpy
.props
.StringProperty(
628 description
="Bone to use for the mirroring",
634 _unregister_message_bus()
635 bpy
.app
.handlers
.load_post
.remove(_on_blendfile_load_post
)
637 del bpy
.types
.Scene
.addon_copy_global_transform_mirror_ob
638 del bpy
.types
.Scene
.addon_copy_global_transform_mirror_bone