File headers: use SPDX license identifiers
[blender-addons.git] / copy_global_transform.py
blob31a9646ac3bd55f4022f3c415b28e44bcdf24576
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 """
4 Copy Global Transform
6 Simple add-on for copying world-space transforms.
8 It's called "global" to avoid confusion with the Blender World data-block.
9 """
11 bl_info = {
12 "name": "Copy Global Transform",
13 "author": "Sybren A. Stüvel",
14 "version": (2, 0),
15 "blender": (3, 1, 0),
16 "location": "N-panel in the 3D Viewport",
17 "category": "Animation",
18 "support": 'OFFICIAL',
21 import ast
22 from typing import Iterable, Optional, Union, Any
24 import bpy
25 from bpy.types import Context, Object, Operator, Panel, PoseBone
26 from mathutils import Matrix
29 class AutoKeying:
30 """Auto-keying support.
32 Based on Rigify code by Alexander Gavrilov.
33 """
35 @classmethod
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
41 options = set()
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')
51 return options
53 @classmethod
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:
60 return None
62 if ts.use_keyframe_insert_keyingset:
63 # No support for keying sets (yet).
64 return None
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')
73 return options
75 @staticmethod
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]
80 else:
81 return [all(bone.lock_rotation)] * 4
83 @staticmethod
84 def keyframe_channels(
85 target: Union[Object, PoseBone],
86 options: set[str],
87 data_path: str,
88 group: str,
89 locks: Iterable[bool],
90 ) -> None:
91 if all(locks):
92 return
94 if not any(locks):
95 target.keyframe_insert(data_path, group=group, options=options)
96 return
98 for index, lock in enumerate(locks):
99 if lock:
100 continue
101 target.keyframe_insert(data_path, index=index, group=group, options=options)
103 @classmethod
104 def key_transformation(
105 cls,
106 target: Union[Object, PoseBone],
107 options: set[str],
108 ) -> None:
109 """Keyframe transformation properties, avoiding keying locked channels."""
111 is_bone = isinstance(target, PoseBone)
112 if is_bone:
113 group = target.name
114 else:
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))
127 else:
128 keyframe("rotation_euler", target.lock_rotation)
130 keyframe("scale", target.lock_scale)
132 @classmethod
133 def autokey_transformation(cls, context: Context, target: Union[Object, PoseBone]) -> None:
134 """Auto-key transformation properties."""
136 options = cls.autokeying_options(context)
137 if options is None:
138 return
139 cls.key_transformation(target, options)
142 def get_matrix(context: Context) -> Matrix:
143 bone = context.active_pose_bone
144 if bone:
145 # Convert matrix to world space
146 arm = context.active_object
147 mat = arm.matrix_world @ bone.matrix
148 else:
149 mat = context.active_object.matrix_world
151 return mat
154 def set_matrix(context: Context, mat: Matrix) -> None:
155 bone = context.active_pose_bone
156 if 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)
161 else:
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
172 if 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
201 if action is None:
202 return []
204 keyframes = set()
205 for fcurve in action.fcurves:
206 if not fcurve.data_path.startswith(rna_path_prefix):
207 continue
209 for kp in fcurve.keyframe_points:
210 if not kp.select_control_point:
211 continue
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"
219 bl_description = (
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'}
225 @classmethod
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))"
234 return {'FINISHED'}
237 class OBJECT_OT_paste_transform(Operator):
238 bl_idname = "object.paste_transform"
239 bl_label = "Paste Global Transform"
240 bl_description = (
241 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
243 bl_options = {'REGISTER', 'UNDO'}
245 _method_items = [
247 'CURRENT',
248 "Current Transform",
249 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
252 'EXISTING_KEYS',
253 "Selected Keys",
254 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
257 'BAKE',
258 "Bake on Key Range",
259 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
262 method: bpy.props.EnumProperty( # type: ignore
263 items=_method_items,
264 name="Paste Method",
265 description="Update the current transform, selected keyframes, or even create new keys",
267 bake_step: bpy.props.IntProperty( # type: ignore
268 name="Frame Step",
269 description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
270 min=1,
271 soft_min=1,
272 soft_max=5,
275 @classmethod
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")
279 return False
280 if not context.window_manager.clipboard.startswith("Matrix("):
281 cls.poll_message_set("Clipboard does not contain a valid matrix")
282 return False
283 return True
285 @staticmethod
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()
293 if len(lines) != 4:
294 return None
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:]))
303 else:
304 mat = self.parse_print_m4(clipboard)
306 if mat is None:
307 self.report({'ERROR'}, "Clipboard does not contain a valid matrix")
308 return {'CANCELLED'}
310 applicator = {
311 'CURRENT': self._paste_current,
312 'EXISTING_KEYS': self._paste_existing_keys,
313 'BAKE': self._paste_bake,
314 }[self.method]
315 return applicator(context, mat)
317 @staticmethod
318 def _paste_current(context: Context, matrix: Matrix) -> set[str]:
319 set_matrix(context, matrix)
320 return {'FINISHED'}
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")
325 return {'CANCELLED'}
327 frame_numbers = _selected_keyframes(context)
328 if not frame_numbers:
329 self.report({'WARNING'}, "No selected frames found")
330 return {'CANCELLED'}
332 self._paste_on_frames(context, frame_numbers, matrix)
333 return {'FINISHED'}
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")
338 return {'CANCELLED'}
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)
347 return {'FINISHED'}
349 def _determine_bake_range(self, context: Context) -> tuple[float, float]:
350 frame_numbers = _selected_keyframes(context)
351 if frame_numbers:
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
364 try:
365 for frame in frame_numbers:
366 context.scene.frame_set(int(frame), subframe=frame % 1.0)
367 set_matrix(context, matrix)
368 finally:
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:
379 layout = self.layout
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
389 if not 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",
395 icon='PASTEDOWN',
396 ).method = 'EXISTING_KEYS'
397 wants_autokey_col.operator(
398 "object.paste_transform",
399 text="Paste and Bake",
400 icon='PASTEDOWN',
401 ).method = '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:
413 continue
414 area.tag_redraw()
417 classes = (
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"),
428 owner=_msgbus_owner,
429 args=(),
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()
445 def register():
446 _register()
447 bpy.app.handlers.load_post.append(_on_blendfile_load_post)
450 def unregister():
451 _unregister()
452 _unregister_message_bus()
453 bpy.app.handlers.load_post.remove(_on_blendfile_load_post)