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",
23 from typing
import Iterable
, Optional
, Union
, Any
26 from bpy
.types
import Context
, Object
, Operator
, Panel
, PoseBone
27 from mathutils
import Matrix
31 """Auto-keying support.
33 Based on Rigify code by Alexander Gavrilov.
37 def keying_options(cls
, context
: Context
) -> set[str]:
38 """Retrieve the general keyframing options from user preferences."""
40 prefs
= context
.preferences
41 ts
= context
.scene
.tool_settings
44 if prefs
.edit
.use_visual_keying
:
45 options
.add('INSERTKEY_VISUAL')
46 if prefs
.edit
.use_keyframe_insert_needed
:
47 options
.add('INSERTKEY_NEEDED')
48 if prefs
.edit
.use_insertkey_xyz_to_rgb
:
49 options
.add('INSERTKEY_XYZ_TO_RGB')
50 if ts
.use_keyframe_cycle_aware
:
51 options
.add('INSERTKEY_CYCLE_AWARE')
55 def autokeying_options(cls
, context
: Context
) -> Optional
[set[str]]:
56 """Retrieve the Auto Keyframe options, or None if disabled."""
58 ts
= context
.scene
.tool_settings
60 if not ts
.use_keyframe_insert_auto
:
63 if ts
.use_keyframe_insert_keyingset
:
64 # No support for keying sets (yet).
67 prefs
= context
.preferences
68 options
= cls
.keying_options(context
)
70 if prefs
.edit
.use_keyframe_insert_available
:
71 options
.add('INSERTKEY_AVAILABLE')
72 if ts
.auto_keying_mode
== 'REPLACE_KEYS':
73 options
.add('INSERTKEY_REPLACE')
77 def get_4d_rotlock(bone
: PoseBone
) -> Iterable
[bool]:
78 "Retrieve the lock status for 4D rotation."
79 if bone
.lock_rotations_4d
:
80 return [bone
.lock_rotation_w
, *bone
.lock_rotation
]
82 return [all(bone
.lock_rotation
)] * 4
85 def keyframe_channels(
86 target
: Union
[Object
, PoseBone
],
90 locks
: Iterable
[bool],
96 target
.keyframe_insert(data_path
, group
=group
, options
=options
)
99 for index
, lock
in enumerate(locks
):
102 target
.keyframe_insert(data_path
, index
=index
, group
=group
, options
=options
)
105 def key_transformation(
107 target
: Union
[Object
, PoseBone
],
110 """Keyframe transformation properties, avoiding keying locked channels."""
112 is_bone
= isinstance(target
, PoseBone
)
116 group
= "Object Transforms"
118 def keyframe(data_path
: str, locks
: Iterable
[bool]) -> None:
119 cls
.keyframe_channels(target
, options
, data_path
, group
, locks
)
121 if not (is_bone
and target
.bone
.use_connect
):
122 keyframe("location", target
.lock_location
)
124 if target
.rotation_mode
== 'QUATERNION':
125 keyframe("rotation_quaternion", cls
.get_4d_rotlock(target
))
126 elif target
.rotation_mode
== 'AXIS_ANGLE':
127 keyframe("rotation_axis_angle", cls
.get_4d_rotlock(target
))
129 keyframe("rotation_euler", target
.lock_rotation
)
131 keyframe("scale", target
.lock_scale
)
134 def autokey_transformation(cls
, context
: Context
, target
: Union
[Object
, PoseBone
]) -> None:
135 """Auto-key transformation properties."""
137 options
= cls
.autokeying_options(context
)
140 cls
.key_transformation(target
, options
)
143 def get_matrix(context
: Context
) -> Matrix
:
144 bone
= context
.active_pose_bone
146 # Convert matrix to world space
147 arm
= context
.active_object
148 mat
= arm
.matrix_world
@ bone
.matrix
150 mat
= context
.active_object
.matrix_world
155 def set_matrix(context
: Context
, mat
: Matrix
) -> None:
156 bone
= context
.active_pose_bone
158 # Convert matrix to local space
159 arm_eval
= context
.active_object
.evaluated_get(context
.view_layer
.depsgraph
)
160 bone
.matrix
= arm_eval
.matrix_world
.inverted() @ mat
161 AutoKeying
.autokey_transformation(context
, bone
)
163 context
.active_object
.matrix_world
= mat
164 AutoKeying
.autokey_transformation(context
, context
.active_object
)
167 def _selected_keyframes(context
: Context
) -> list[float]:
168 """Return the list of frame numbers that have a selected key.
170 Only keys on the active bone/object are considered.
172 bone
= context
.active_pose_bone
174 return _selected_keyframes_for_bone(context
.active_object
, bone
)
175 return _selected_keyframes_for_object(context
.active_object
)
178 def _selected_keyframes_for_bone(object: Object
, bone
: PoseBone
) -> list[float]:
179 """Return the list of frame numbers that have a selected key.
181 Only keys on the given pose bone are considered.
183 name
= bpy
.utils
.escape_identifier(bone
.name
)
184 return _selected_keyframes_in_action(object, f
'pose.bones["{name}"].')
187 def _selected_keyframes_for_object(object: Object
) -> list[float]:
188 """Return the list of frame numbers that have a selected key.
190 Only keys on the given object are considered.
192 return _selected_keyframes_in_action(object, "")
195 def _selected_keyframes_in_action(object: Object
, rna_path_prefix
: str) -> list[float]:
196 """Return the list of frame numbers that have a selected key.
198 Only keys on the given object's Action on FCurves starting with rna_path_prefix are considered.
201 action
= object.animation_data
and object.animation_data
.action
206 for fcurve
in action
.fcurves
:
207 if not fcurve
.data_path
.startswith(rna_path_prefix
):
210 for kp
in fcurve
.keyframe_points
:
211 if not kp
.select_control_point
:
213 keyframes
.add(kp
.co
.x
)
214 return sorted(keyframes
)
217 class OBJECT_OT_copy_global_transform(Operator
):
218 bl_idname
= "object.copy_global_transform"
219 bl_label
= "Copy Global Transform"
221 "Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
223 # This operator cannot be un-done because it manipulates data outside Blender.
224 bl_options
= {'REGISTER'}
227 def poll(cls
, context
: Context
) -> bool:
228 return bool(context
.active_pose_bone
) or bool(context
.active_object
)
230 def execute(self
, context
: Context
) -> set[str]:
231 mat
= get_matrix(context
)
232 rows
= [f
" {tuple(row)!r}," for row
in mat
]
233 as_string
= "\n".join(rows
)
234 context
.window_manager
.clipboard
= f
"Matrix((\n{as_string}\n))"
238 class OBJECT_OT_paste_transform(Operator
):
239 bl_idname
= "object.paste_transform"
240 bl_label
= "Paste Global Transform"
242 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
244 bl_options
= {'REGISTER', 'UNDO'}
250 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
255 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
260 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
263 method
: bpy
.props
.EnumProperty( # type: ignore
266 description
="Update the current transform, selected keyframes, or even create new keys",
268 bake_step
: bpy
.props
.IntProperty( # type: ignore
270 description
="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
277 def poll(cls
, context
: Context
) -> bool:
278 if not context
.active_pose_bone
and not context
.active_object
:
279 cls
.poll_message_set("Select an object or pose bone")
281 if not context
.window_manager
.clipboard
.startswith("Matrix("):
282 cls
.poll_message_set("Clipboard does not contain a valid matrix")
287 def parse_print_m4(value
: str) -> Optional
[Matrix
]:
288 """Parse output from Blender's print_m4() function.
290 Expects four lines of space-separated floats.
293 lines
= value
.strip().splitlines()
297 floats
= tuple(tuple(float(item
) for item
in line
.split()) for line
in lines
)
298 return Matrix(floats
)
300 def execute(self
, context
: Context
) -> set[str]:
301 clipboard
= context
.window_manager
.clipboard
302 if clipboard
.startswith("Matrix"):
303 mat
= Matrix(ast
.literal_eval(clipboard
[6:]))
305 mat
= self
.parse_print_m4(clipboard
)
308 self
.report({'ERROR'}, "Clipboard does not contain a valid matrix")
312 'CURRENT': self
._paste
_current
,
313 'EXISTING_KEYS': self
._paste
_existing
_keys
,
314 'BAKE': self
._paste
_bake
,
316 return applicator(context
, mat
)
319 def _paste_current(context
: Context
, matrix
: Matrix
) -> set[str]:
320 set_matrix(context
, matrix
)
323 def _paste_existing_keys(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
324 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
325 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
328 frame_numbers
= _selected_keyframes(context
)
329 if not frame_numbers
:
330 self
.report({'WARNING'}, "No selected frames found")
333 self
._paste
_on
_frames
(context
, frame_numbers
, matrix
)
336 def _paste_bake(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
337 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
338 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
341 bake_step
= max(1, self
.bake_step
)
342 # Put the clamped bake step back into RNA for the redo panel.
343 self
.bake_step
= bake_step
345 frame_start
, frame_end
= self
._determine
_bake
_range
(context
)
346 frame_range
= range(round(frame_start
), round(frame_end
) + bake_step
, bake_step
)
347 self
._paste
_on
_frames
(context
, frame_range
, matrix
)
350 def _determine_bake_range(self
, context
: Context
) -> tuple[float, float]:
351 frame_numbers
= _selected_keyframes(context
)
353 # Note that these could be the same frame, if len(frame_numbers) == 1:
354 return frame_numbers
[0], frame_numbers
[-1]
356 if context
.scene
.use_preview_range
:
357 self
.report({'INFO'}, "No selected keys, pasting over preview range")
358 return context
.scene
.frame_preview_start
, context
.scene
.frame_preview_end
360 self
.report({'INFO'}, "No selected keys, pasting over scene range")
361 return context
.scene
.frame_start
, context
.scene
.frame_end
363 def _paste_on_frames(self
, context
: Context
, frame_numbers
: Iterable
[float], matrix
: Matrix
) -> None:
364 current_frame
= context
.scene
.frame_current_final
366 for frame
in frame_numbers
:
367 context
.scene
.frame_set(int(frame
), subframe
=frame
% 1.0)
368 set_matrix(context
, matrix
)
370 context
.scene
.frame_set(int(current_frame
), subframe
=current_frame
% 1.0)
373 class VIEW3D_PT_copy_global_transform(Panel
):
374 bl_space_type
= 'VIEW_3D'
375 bl_region_type
= 'UI'
376 bl_category
= "Animation"
377 bl_label
= "Global Transform"
379 def draw(self
, context
: Context
) -> None:
382 # No need to put "Global Transform" in the operator text, given that it's already in the panel title.
383 layout
.operator("object.copy_global_transform", text
="Copy", icon
='COPYDOWN')
385 paste_col
= layout
.column(align
=True)
386 paste_col
.operator("object.paste_transform", text
="Paste", icon
='PASTEDOWN').method
= 'CURRENT'
387 wants_autokey_col
= paste_col
.column(align
=True)
388 has_autokey
= context
.scene
.tool_settings
.use_keyframe_insert_auto
389 wants_autokey_col
.enabled
= has_autokey
391 wants_autokey_col
.label(text
="These require auto-key:")
393 wants_autokey_col
.operator(
394 "object.paste_transform",
395 text
="Paste to Selected Keys",
397 ).method
= 'EXISTING_KEYS'
398 wants_autokey_col
.operator(
399 "object.paste_transform",
400 text
="Paste and Bake",
405 ### Messagebus subscription to monitor changes & refresh panels.
406 _msgbus_owner
= object()
409 def _refresh_3d_panels():
410 refresh_area_types
= {'VIEW_3D'}
411 for win
in bpy
.context
.window_manager
.windows
:
412 for area
in win
.screen
.areas
:
413 if area
.type not in refresh_area_types
:
419 OBJECT_OT_copy_global_transform
,
420 OBJECT_OT_paste_transform
,
421 VIEW3D_PT_copy_global_transform
,
423 _register
, _unregister
= bpy
.utils
.register_classes_factory(classes
)
426 def _register_message_bus() -> None:
427 bpy
.msgbus
.subscribe_rna(
428 key
=(bpy
.types
.ToolSettings
, "use_keyframe_insert_auto"),
431 notify
=_refresh_3d_panels
,
432 options
={'PERSISTENT'},
436 def _unregister_message_bus() -> None:
437 bpy
.msgbus
.clear_by_owner(_msgbus_owner
)
440 @bpy.app
.handlers
.persistent
# type: ignore
441 def _on_blendfile_load_post(none
: Any
, other_none
: Any
) -> None:
442 # The parameters are required, but both are None.
443 _register_message_bus()
448 bpy
.app
.handlers
.load_post
.append(_on_blendfile_load_post
)
453 _unregister_message_bus()
454 bpy
.app
.handlers
.load_post
.remove(_on_blendfile_load_post
)