FBX Import: Speed up parsing by reading entire subtrees into BytesIO
[blender-addons.git] / copy_global_transform.py
blob7f6e02a36f52d0804008180736312707a31108a3
1 # SPDX-FileCopyrightText: 2021-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Copy Global Transform
8 Simple add-on for copying world-space transforms.
10 It's called "global" to avoid confusion with the Blender World data-block.
11 """
13 bl_info = {
14 "name": "Copy Global Transform",
15 "author": "Sybren A. Stüvel",
16 "version": (2, 1),
17 "blender": (3, 5, 0),
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",
25 import ast
26 from typing import Iterable, Optional, Union, Any
28 import bpy
29 from bpy.types import Context, Object, Operator, Panel, PoseBone, UILayout
30 from mathutils import Matrix
33 _axis_enum_items = [
34 ("x", "X", "", 1),
35 ("y", "Y", "", 2),
36 ("z", "Z", "", 3),
40 class AutoKeying:
41 """Auto-keying support.
43 Based on Rigify code by Alexander Gavrilov.
44 """
46 @classmethod
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
52 options = set()
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')
62 return options
64 @classmethod
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:
71 return None
73 if ts.use_keyframe_insert_keyingset:
74 # No support for keying sets (yet).
75 return None
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')
84 return options
86 @staticmethod
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]
91 else:
92 return [all(bone.lock_rotation)] * 4
94 @staticmethod
95 def keyframe_channels(
96 target: Union[Object, PoseBone],
97 options: set[str],
98 data_path: str,
99 group: str,
100 locks: Iterable[bool],
101 ) -> None:
102 if all(locks):
103 return
105 if not any(locks):
106 target.keyframe_insert(data_path, group=group, options=options)
107 return
109 for index, lock in enumerate(locks):
110 if lock:
111 continue
112 target.keyframe_insert(data_path, index=index, group=group, options=options)
114 @classmethod
115 def key_transformation(
116 cls,
117 target: Union[Object, PoseBone],
118 options: set[str],
119 ) -> None:
120 """Keyframe transformation properties, avoiding keying locked channels."""
122 is_bone = isinstance(target, PoseBone)
123 if is_bone:
124 group = target.name
125 else:
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))
138 else:
139 keyframe("rotation_euler", target.lock_rotation)
141 keyframe("scale", target.lock_scale)
143 @classmethod
144 def autokey_transformation(cls, context: Context, target: Union[Object, PoseBone]) -> None:
145 """Auto-key transformation properties."""
147 options = cls.autokeying_options(context)
148 if options is None:
149 return
150 cls.key_transformation(target, options)
153 def get_matrix(context: Context) -> Matrix:
154 bone = context.active_pose_bone
155 if bone:
156 # Convert matrix to world space
157 arm = context.active_object
158 mat = arm.matrix_world @ bone.matrix
159 else:
160 mat = context.active_object.matrix_world
162 return mat
165 def set_matrix(context: Context, mat: Matrix) -> None:
166 bone = context.active_pose_bone
167 if 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)
172 else:
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
183 if 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
212 if action is None:
213 return []
215 keyframes = set()
216 for fcurve in action.fcurves:
217 if not fcurve.data_path.startswith(rna_path_prefix):
218 continue
220 for kp in fcurve.keyframe_points:
221 if not kp.select_control_point:
222 continue
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"
230 bl_description = (
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'}
236 @classmethod
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))"
245 return {'FINISHED'}
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"
255 bl_description = (
256 "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
258 bl_options = {'REGISTER', 'UNDO'}
260 _method_items = [
262 'CURRENT',
263 "Current Transform",
264 "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
267 'EXISTING_KEYS',
268 "Selected Keys",
269 "Paste onto frames that have a selected key, potentially creating new keys on those frames",
272 'BAKE',
273 "Bake on Key Range",
274 "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
277 method: bpy.props.EnumProperty( # type: ignore
278 items=_method_items,
279 name="Paste Method",
280 description="Update the current transform, selected keyframes, or even create new keys",
282 bake_step: bpy.props.IntProperty( # type: ignore
283 name="Frame Step",
284 description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
285 min=1,
286 soft_min=1,
287 soft_max=5,
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",
293 default=False,
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",
300 default='x',
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",
306 default='z',
309 @classmethod
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")
313 return False
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")
318 return False
319 return True
321 @staticmethod
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()
329 if len(lines) != 4:
330 return None
332 floats = tuple(tuple(float(item) for item in line.split()) for line in lines)
333 return Matrix(floats)
335 @staticmethod
336 def parse_repr_m4(value: str) -> Optional[Matrix]:
337 """Four lines of (a, b, c, d) floats."""
339 lines = value.strip().splitlines()
340 if len(lines) != 4:
341 return None
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])
352 else:
353 mat = self.parse_print_m4(clipboard)
355 if mat is None:
356 self.report({'ERROR'}, "Clipboard does not contain a valid matrix")
357 return {'CANCELLED'}
359 try:
360 mat = self._maybe_mirror(context, mat)
361 except UnableToMirrorError:
362 self.report({'ERROR'}, "Unable to mirror, no mirror object/bone configured")
363 return {'CANCELLED'}
365 applicator = {
366 'CURRENT': self._paste_current,
367 'EXISTING_KEYS': self._paste_existing_keys,
368 'BAKE': self._paste_bake,
369 }[self.method]
370 return applicator(context, mat)
372 def _maybe_mirror(self, context: Context, matrix: Matrix) -> Matrix:
373 if not self.use_mirror:
374 return matrix
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':
382 mirror_ob = ctx_ob
384 if not mirror_ob:
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:
414 case 'x':
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.
418 case 'y':
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.
422 case 'z':
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
434 @staticmethod
435 def _paste_current(context: Context, matrix: Matrix) -> set[str]:
436 set_matrix(context, matrix)
437 return {'FINISHED'}
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")
442 return {'CANCELLED'}
444 frame_numbers = _selected_keyframes(context)
445 if not frame_numbers:
446 self.report({'WARNING'}, "No selected frames found")
447 return {'CANCELLED'}
449 self._paste_on_frames(context, frame_numbers, matrix)
450 return {'FINISHED'}
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")
455 return {'CANCELLED'}
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)
464 return {'FINISHED'}
466 def _determine_bake_range(self, context: Context) -> tuple[float, float]:
467 frame_numbers = _selected_keyframes(context)
468 if frame_numbers:
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
481 try:
482 for frame in frame_numbers:
483 context.scene.frame_set(int(frame), subframe=frame % 1.0)
484 set_matrix(context, matrix)
485 finally:
486 context.scene.frame_set(int(current_frame), subframe=current_frame % 1.0)
489 class PanelMixin:
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:
499 layout = self.layout
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
517 if not 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",
523 icon='PASTEDOWN',
524 ).method = 'EXISTING_KEYS'
525 wants_autokey_col.operator(
526 "object.paste_transform",
527 text="Paste and Bake",
528 icon='PASTEDOWN',
529 ).method = '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:
537 layout = self.layout
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)
546 else:
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'
555 layout.prop_search(
556 scene,
557 "addon_copy_global_transform_mirror_bone",
558 armature_ob.data,
559 "edit_bones" if armature_ob.mode == 'EDIT' else "bones",
560 text="Bone",
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:
577 continue
578 area.tag_redraw()
581 classes = (
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"),
593 owner=_msgbus_owner,
594 args=(),
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()
610 def register():
611 _register()
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(
627 name="Mirror Bone",
628 description="Bone to use for the mirroring",
632 def unregister():
633 _unregister()
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