Cleanup: quiet float argument to in type warning
[blender-addons.git] / copy_global_transform.py
blobd468497a04d787448b5b9c92d41d065cba2c4f7b
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',
19 "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/copy_global_transform.html",
22 import ast
23 from typing import Iterable, Optional, Union, Any
25 import bpy
26 from bpy.types import Context, Object, Operator, Panel, PoseBone
27 from mathutils import Matrix
30 class AutoKeying:
31 """Auto-keying support.
33 Based on Rigify code by Alexander Gavrilov.
34 """
36 @classmethod
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
42 options = set()
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')
52 return options
54 @classmethod
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:
61 return None
63 if ts.use_keyframe_insert_keyingset:
64 # No support for keying sets (yet).
65 return None
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')
74 return options
76 @staticmethod
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]
81 else:
82 return [all(bone.lock_rotation)] * 4
84 @staticmethod
85 def keyframe_channels(
86 target: Union[Object, PoseBone],
87 options: set[str],
88 data_path: str,
89 group: str,
90 locks: Iterable[bool],
91 ) -> None:
92 if all(locks):
93 return
95 if not any(locks):
96 target.keyframe_insert(data_path, group=group, options=options)
97 return
99 for index, lock in enumerate(locks):
100 if lock:
101 continue
102 target.keyframe_insert(data_path, index=index, group=group, options=options)
104 @classmethod
105 def key_transformation(
106 cls,
107 target: Union[Object, PoseBone],
108 options: set[str],
109 ) -> None:
110 """Keyframe transformation properties, avoiding keying locked channels."""
112 is_bone = isinstance(target, PoseBone)
113 if is_bone:
114 group = target.name
115 else:
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))
128 else:
129 keyframe("rotation_euler", target.lock_rotation)
131 keyframe("scale", target.lock_scale)
133 @classmethod
134 def autokey_transformation(cls, context: Context, target: Union[Object, PoseBone]) -> None:
135 """Auto-key transformation properties."""
137 options = cls.autokeying_options(context)
138 if options is None:
139 return
140 cls.key_transformation(target, options)
143 def get_matrix(context: Context) -> Matrix:
144 bone = context.active_pose_bone
145 if bone:
146 # Convert matrix to world space
147 arm = context.active_object
148 mat = arm.matrix_world @ bone.matrix
149 else:
150 mat = context.active_object.matrix_world
152 return mat
155 def set_matrix(context: Context, mat: Matrix) -> None:
156 bone = context.active_pose_bone
157 if 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)
162 else:
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
173 if 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
202 if action is None:
203 return []
205 keyframes = set()
206 for fcurve in action.fcurves:
207 if not fcurve.data_path.startswith(rna_path_prefix):
208 continue
210 for kp in fcurve.keyframe_points:
211 if not kp.select_control_point:
212 continue
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"
220 bl_description = (
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'}
226 @classmethod
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))"
235 return {'FINISHED'}
238 class OBJECT_OT_paste_transform(Operator):
239 bl_idname = "object.paste_transform"
240 bl_label = "Paste Global Transform"
241 bl_description = (
242 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
244 bl_options = {'REGISTER', 'UNDO'}
246 _method_items = [
248 'CURRENT',
249 "Current Transform",
250 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
253 'EXISTING_KEYS',
254 "Selected Keys",
255 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
258 'BAKE',
259 "Bake on Key Range",
260 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
263 method: bpy.props.EnumProperty( # type: ignore
264 items=_method_items,
265 name="Paste Method",
266 description="Update the current transform, selected keyframes, or even create new keys",
268 bake_step: bpy.props.IntProperty( # type: ignore
269 name="Frame Step",
270 description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
271 min=1,
272 soft_min=1,
273 soft_max=5,
276 @classmethod
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")
280 return False
281 if not context.window_manager.clipboard.startswith("Matrix("):
282 cls.poll_message_set("Clipboard does not contain a valid matrix")
283 return False
284 return True
286 @staticmethod
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()
294 if len(lines) != 4:
295 return None
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:]))
304 else:
305 mat = self.parse_print_m4(clipboard)
307 if mat is None:
308 self.report({'ERROR'}, "Clipboard does not contain a valid matrix")
309 return {'CANCELLED'}
311 applicator = {
312 'CURRENT': self._paste_current,
313 'EXISTING_KEYS': self._paste_existing_keys,
314 'BAKE': self._paste_bake,
315 }[self.method]
316 return applicator(context, mat)
318 @staticmethod
319 def _paste_current(context: Context, matrix: Matrix) -> set[str]:
320 set_matrix(context, matrix)
321 return {'FINISHED'}
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")
326 return {'CANCELLED'}
328 frame_numbers = _selected_keyframes(context)
329 if not frame_numbers:
330 self.report({'WARNING'}, "No selected frames found")
331 return {'CANCELLED'}
333 self._paste_on_frames(context, frame_numbers, matrix)
334 return {'FINISHED'}
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")
339 return {'CANCELLED'}
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)
348 return {'FINISHED'}
350 def _determine_bake_range(self, context: Context) -> tuple[float, float]:
351 frame_numbers = _selected_keyframes(context)
352 if frame_numbers:
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
365 try:
366 for frame in frame_numbers:
367 context.scene.frame_set(int(frame), subframe=frame % 1.0)
368 set_matrix(context, matrix)
369 finally:
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:
380 layout = self.layout
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
390 if not 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",
396 icon='PASTEDOWN',
397 ).method = 'EXISTING_KEYS'
398 wants_autokey_col.operator(
399 "object.paste_transform",
400 text="Paste and Bake",
401 icon='PASTEDOWN',
402 ).method = '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:
414 continue
415 area.tag_redraw()
418 classes = (
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"),
429 owner=_msgbus_owner,
430 args=(),
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()
446 def register():
447 _register()
448 bpy.app.handlers.load_post.append(_on_blendfile_load_post)
451 def unregister():
452 _unregister()
453 _unregister_message_bus()
454 bpy.app.handlers.load_post.remove(_on_blendfile_load_post)