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',
22 from typing
import Iterable
, Optional
, Union
, Any
25 from bpy
.types
import Context
, Object
, Operator
, Panel
, PoseBone
26 from mathutils
import Matrix
30 """Auto-keying support.
32 Based on Rigify code by Alexander Gavrilov.
36 def keying_options(cls
, context
: Context
) -> set[str]:
37 """Retrieve the general keyframing options from user preferences."""
39 prefs
= context
.preferences
40 ts
= context
.scene
.tool_settings
43 if prefs
.edit
.use_visual_keying
:
44 options
.add('INSERTKEY_VISUAL')
45 if prefs
.edit
.use_keyframe_insert_needed
:
46 options
.add('INSERTKEY_NEEDED')
47 if prefs
.edit
.use_insertkey_xyz_to_rgb
:
48 options
.add('INSERTKEY_XYZ_TO_RGB')
49 if ts
.use_keyframe_cycle_aware
:
50 options
.add('INSERTKEY_CYCLE_AWARE')
54 def autokeying_options(cls
, context
: Context
) -> Optional
[set[str]]:
55 """Retrieve the Auto Keyframe options, or None if disabled."""
57 ts
= context
.scene
.tool_settings
59 if not ts
.use_keyframe_insert_auto
:
62 if ts
.use_keyframe_insert_keyingset
:
63 # No support for keying sets (yet).
66 prefs
= context
.preferences
67 options
= cls
.keying_options(context
)
69 if prefs
.edit
.use_keyframe_insert_available
:
70 options
.add('INSERTKEY_AVAILABLE')
71 if ts
.auto_keying_mode
== 'REPLACE_KEYS':
72 options
.add('INSERTKEY_REPLACE')
76 def get_4d_rotlock(bone
: PoseBone
) -> Iterable
[bool]:
77 "Retrieve the lock status for 4D rotation."
78 if bone
.lock_rotations_4d
:
79 return [bone
.lock_rotation_w
, *bone
.lock_rotation
]
81 return [all(bone
.lock_rotation
)] * 4
84 def keyframe_channels(
85 target
: Union
[Object
, PoseBone
],
89 locks
: Iterable
[bool],
95 target
.keyframe_insert(data_path
, group
=group
, options
=options
)
98 for index
, lock
in enumerate(locks
):
101 target
.keyframe_insert(data_path
, index
=index
, group
=group
, options
=options
)
104 def key_transformation(
106 target
: Union
[Object
, PoseBone
],
109 """Keyframe transformation properties, avoiding keying locked channels."""
111 is_bone
= isinstance(target
, PoseBone
)
115 group
= "Object Transforms"
117 def keyframe(data_path
: str, locks
: Iterable
[bool]) -> None:
118 cls
.keyframe_channels(target
, options
, data_path
, group
, locks
)
120 if not (is_bone
and target
.bone
.use_connect
):
121 keyframe("location", target
.lock_location
)
123 if target
.rotation_mode
== 'QUATERNION':
124 keyframe("rotation_quaternion", cls
.get_4d_rotlock(target
))
125 elif target
.rotation_mode
== 'AXIS_ANGLE':
126 keyframe("rotation_axis_angle", cls
.get_4d_rotlock(target
))
128 keyframe("rotation_euler", target
.lock_rotation
)
130 keyframe("scale", target
.lock_scale
)
133 def autokey_transformation(cls
, context
: Context
, target
: Union
[Object
, PoseBone
]) -> None:
134 """Auto-key transformation properties."""
136 options
= cls
.autokeying_options(context
)
139 cls
.key_transformation(target
, options
)
142 def get_matrix(context
: Context
) -> Matrix
:
143 bone
= context
.active_pose_bone
145 # Convert matrix to world space
146 arm
= context
.active_object
147 mat
= arm
.matrix_world
@ bone
.matrix
149 mat
= context
.active_object
.matrix_world
154 def set_matrix(context
: Context
, mat
: Matrix
) -> None:
155 bone
= context
.active_pose_bone
157 # Convert matrix to local space
158 arm_eval
= context
.active_object
.evaluated_get(context
.view_layer
.depsgraph
)
159 bone
.matrix
= arm_eval
.matrix_world
.inverted() @ mat
160 AutoKeying
.autokey_transformation(context
, bone
)
162 context
.active_object
.matrix_world
= mat
163 AutoKeying
.autokey_transformation(context
, context
.active_object
)
166 def _selected_keyframes(context
: Context
) -> list[float]:
167 """Return the list of frame numbers that have a selected key.
169 Only keys on the active bone/object are considered.
171 bone
= context
.active_pose_bone
173 return _selected_keyframes_for_bone(context
.active_object
, bone
)
174 return _selected_keyframes_for_object(context
.active_object
)
177 def _selected_keyframes_for_bone(object: Object
, bone
: PoseBone
) -> list[float]:
178 """Return the list of frame numbers that have a selected key.
180 Only keys on the given pose bone are considered.
182 name
= bpy
.utils
.escape_identifier(bone
.name
)
183 return _selected_keyframes_in_action(object, f
'pose.bones["{name}"].')
186 def _selected_keyframes_for_object(object: Object
) -> list[float]:
187 """Return the list of frame numbers that have a selected key.
189 Only keys on the given object are considered.
191 return _selected_keyframes_in_action(object, "")
194 def _selected_keyframes_in_action(object: Object
, rna_path_prefix
: str) -> list[float]:
195 """Return the list of frame numbers that have a selected key.
197 Only keys on the given object's Action on FCurves starting with rna_path_prefix are considered.
200 action
= object.animation_data
and object.animation_data
.action
205 for fcurve
in action
.fcurves
:
206 if not fcurve
.data_path
.startswith(rna_path_prefix
):
209 for kp
in fcurve
.keyframe_points
:
210 if not kp
.select_control_point
:
212 keyframes
.add(kp
.co
.x
)
213 return sorted(keyframes
)
216 class OBJECT_OT_copy_global_transform(Operator
):
217 bl_idname
= "object.copy_global_transform"
218 bl_label
= "Copy Global Transform"
220 "Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
222 # This operator cannot be un-done because it manipulates data outside Blender.
223 bl_options
= {'REGISTER'}
226 def poll(cls
, context
: Context
) -> bool:
227 return bool(context
.active_pose_bone
) or bool(context
.active_object
)
229 def execute(self
, context
: Context
) -> set[str]:
230 mat
= get_matrix(context
)
231 rows
= [f
" {tuple(row)!r}," for row
in mat
]
232 as_string
= "\n".join(rows
)
233 context
.window_manager
.clipboard
= f
"Matrix((\n{as_string}\n))"
237 class OBJECT_OT_paste_transform(Operator
):
238 bl_idname
= "object.paste_transform"
239 bl_label
= "Paste Global Transform"
241 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
243 bl_options
= {'REGISTER', 'UNDO'}
249 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
254 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
259 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
262 method
: bpy
.props
.EnumProperty( # type: ignore
265 description
="Update the current transform, selected keyframes, or even create new keys",
267 bake_step
: bpy
.props
.IntProperty( # type: ignore
269 description
="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
276 def poll(cls
, context
: Context
) -> bool:
277 if not context
.active_pose_bone
and not context
.active_object
:
278 cls
.poll_message_set("Select an object or pose bone")
280 if not context
.window_manager
.clipboard
.startswith("Matrix("):
281 cls
.poll_message_set("Clipboard does not contain a valid matrix")
286 def parse_print_m4(value
: str) -> Optional
[Matrix
]:
287 """Parse output from Blender's print_m4() function.
289 Expects four lines of space-separated floats.
292 lines
= value
.strip().splitlines()
296 floats
= tuple(tuple(float(item
) for item
in line
.split()) for line
in lines
)
297 return Matrix(floats
)
299 def execute(self
, context
: Context
) -> set[str]:
300 clipboard
= context
.window_manager
.clipboard
301 if clipboard
.startswith("Matrix"):
302 mat
= Matrix(ast
.literal_eval(clipboard
[6:]))
304 mat
= self
.parse_print_m4(clipboard
)
307 self
.report({'ERROR'}, "Clipboard does not contain a valid matrix")
311 'CURRENT': self
._paste
_current
,
312 'EXISTING_KEYS': self
._paste
_existing
_keys
,
313 'BAKE': self
._paste
_bake
,
315 return applicator(context
, mat
)
318 def _paste_current(context
: Context
, matrix
: Matrix
) -> set[str]:
319 set_matrix(context
, matrix
)
322 def _paste_existing_keys(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
323 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
324 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
327 frame_numbers
= _selected_keyframes(context
)
328 if not frame_numbers
:
329 self
.report({'WARNING'}, "No selected frames found")
332 self
._paste
_on
_frames
(context
, frame_numbers
, matrix
)
335 def _paste_bake(self
, context
: Context
, matrix
: Matrix
) -> set[str]:
336 if not context
.scene
.tool_settings
.use_keyframe_insert_auto
:
337 self
.report({'ERROR'}, "This mode requires auto-keying to work properly")
340 bake_step
= max(1, self
.bake_step
)
341 # Put the clamped bake step back into RNA for the redo panel.
342 self
.bake_step
= bake_step
344 frame_start
, frame_end
= self
._determine
_bake
_range
(context
)
345 frame_range
= range(round(frame_start
), round(frame_end
) + bake_step
, bake_step
)
346 self
._paste
_on
_frames
(context
, frame_range
, matrix
)
349 def _determine_bake_range(self
, context
: Context
) -> tuple[float, float]:
350 frame_numbers
= _selected_keyframes(context
)
352 # Note that these could be the same frame, if len(frame_numbers) == 1:
353 return frame_numbers
[0], frame_numbers
[-1]
355 if context
.scene
.use_preview_range
:
356 self
.report({'INFO'}, "No selected keys, pasting over preview range")
357 return context
.scene
.frame_preview_start
, context
.scene
.frame_preview_end
359 self
.report({'INFO'}, "No selected keys, pasting over scene range")
360 return context
.scene
.frame_start
, context
.scene
.frame_end
362 def _paste_on_frames(self
, context
: Context
, frame_numbers
: Iterable
[float], matrix
: Matrix
) -> None:
363 current_frame
= context
.scene
.frame_current_final
365 for frame
in frame_numbers
:
366 context
.scene
.frame_set(int(frame
), subframe
=frame
% 1.0)
367 set_matrix(context
, matrix
)
369 context
.scene
.frame_set(int(current_frame
), subframe
=current_frame
% 1.0)
372 class VIEW3D_PT_copy_global_transform(Panel
):
373 bl_space_type
= 'VIEW_3D'
374 bl_region_type
= 'UI'
375 bl_category
= "Animation"
376 bl_label
= "Global Transform"
378 def draw(self
, context
: Context
) -> None:
381 # No need to put "Global Transform" in the operator text, given that it's already in the panel title.
382 layout
.operator("object.copy_global_transform", text
="Copy", icon
='COPYDOWN')
384 paste_col
= layout
.column(align
=True)
385 paste_col
.operator("object.paste_transform", text
="Paste", icon
='PASTEDOWN').method
= 'CURRENT'
386 wants_autokey_col
= paste_col
.column(align
=True)
387 has_autokey
= context
.scene
.tool_settings
.use_keyframe_insert_auto
388 wants_autokey_col
.enabled
= has_autokey
390 wants_autokey_col
.label(text
="These require auto-key:")
392 wants_autokey_col
.operator(
393 "object.paste_transform",
394 text
="Paste to Selected Keys",
396 ).method
= 'EXISTING_KEYS'
397 wants_autokey_col
.operator(
398 "object.paste_transform",
399 text
="Paste and Bake",
404 ### Messagebus subscription to monitor changes & refresh panels.
405 _msgbus_owner
= object()
408 def _refresh_3d_panels():
409 refresh_area_types
= {'VIEW_3D'}
410 for win
in bpy
.context
.window_manager
.windows
:
411 for area
in win
.screen
.areas
:
412 if area
.type not in refresh_area_types
:
418 OBJECT_OT_copy_global_transform
,
419 OBJECT_OT_paste_transform
,
420 VIEW3D_PT_copy_global_transform
,
422 _register
, _unregister
= bpy
.utils
.register_classes_factory(classes
)
425 def _register_message_bus() -> None:
426 bpy
.msgbus
.subscribe_rna(
427 key
=(bpy
.types
.ToolSettings
, "use_keyframe_insert_auto"),
430 notify
=_refresh_3d_panels
,
431 options
={'PERSISTENT'},
435 def _unregister_message_bus() -> None:
436 bpy
.msgbus
.clear_by_owner(_msgbus_owner
)
439 @bpy.app
.handlers
.persistent
# type: ignore
440 def _on_blendfile_load_post(none
: Any
, other_none
: Any
) -> None:
441 # The parameters are required, but both are None.
442 _register_message_bus()
447 bpy
.app
.handlers
.load_post
.append(_on_blendfile_load_post
)
452 _unregister_message_bus()
453 bpy
.app
.handlers
.load_post
.remove(_on_blendfile_load_post
)