From d737f2016dcc314ac562e0e7db4381a6281af19f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= <sybren@blender.org>
Date: Thu, 16 Dec 2021 11:10:11 +0100
Subject: [PATCH] New animation add-on: Copy Global Transform

This add-on allows animators to copy the global transform of the active
object or pose bone onto the clipboard. It can then be pasted in three
different ways:

- To the current transform of the selected object/pose bone (could be a
  different one than was used for the copying).
- To selected keyframes.
- Baking to all frames between the first and last selected keyframe
  (defaulting to preview range or scene range).

All three methods are compatible with auto-keying.

The latter two methods *require* auto-keying to be enabled, to give the
animator control over which keying set to use, etc.

An earlier version of this add-on was used by the Blender Animation
Studio during Sprite Fright. Since then the two paste-to-frame-range
options were added, by request of the animators.

Reviewed by: campbellbarton

Differential Revision: https://developer.blender.org/D13570
---
 copy_global_transform.py | 469 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 469 insertions(+)
 create mode 100644 copy_global_transform.py

diff --git a/copy_global_transform.py b/copy_global_transform.py
new file mode 100644
index 000000000..2c3c50126
--- /dev/null
+++ b/copy_global_transform.py
@@ -0,0 +1,469 @@
+# ====================== BEGIN GPL LICENSE BLOCK ======================
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ======================= END GPL LICENSE BLOCK ========================
+
+"""
+Copy Global Transform
+
+Simple add-on for copying world-space transforms.
+
+It's called "global" to avoid confusion with the Blender World data-block.
+"""
+
+bl_info = {
+    "name": "Copy Global Transform",
+    "author": "Sybren A. StĂĽvel",
+    "version": (2, 0),
+    "blender": (3, 1, 0),
+    "location": "N-panel in the 3D Viewport",
+    "category": "Animation",
+    "support": 'OFFICIAL',
+}
+
+import ast
+from typing import Iterable, Optional, Union, Any
+
+import bpy
+from bpy.types import Context, Object, Operator, Panel, PoseBone
+from mathutils import Matrix
+
+
+class AutoKeying:
+    """Auto-keying support.
+
+    Based on Rigify code by Alexander Gavrilov.
+    """
+
+    @classmethod
+    def keying_options(cls, context: Context) -> set[str]:
+        """Retrieve the general keyframing options from user preferences."""
+
+        prefs = context.preferences
+        ts = context.scene.tool_settings
+        options = set()
+
+        if prefs.edit.use_visual_keying:
+            options.add('INSERTKEY_VISUAL')
+        if prefs.edit.use_keyframe_insert_needed:
+            options.add('INSERTKEY_NEEDED')
+        if prefs.edit.use_insertkey_xyz_to_rgb:
+            options.add('INSERTKEY_XYZ_TO_RGB')
+        if ts.use_keyframe_cycle_aware:
+            options.add('INSERTKEY_CYCLE_AWARE')
+        return options
+
+    @classmethod
+    def autokeying_options(cls, context: Context) -> Optional[set[str]]:
+        """Retrieve the Auto Keyframe options, or None if disabled."""
+
+        ts = context.scene.tool_settings
+
+        if not ts.use_keyframe_insert_auto:
+            return None
+
+        if ts.use_keyframe_insert_keyingset:
+            # No support for keying sets (yet).
+            return None
+
+        prefs = context.preferences
+        options = cls.keying_options(context)
+
+        if prefs.edit.use_keyframe_insert_available:
+            options.add('INSERTKEY_AVAILABLE')
+        if ts.auto_keying_mode == 'REPLACE_KEYS':
+            options.add('INSERTKEY_REPLACE')
+        return options
+
+    @staticmethod
+    def get_4d_rotlock(bone: PoseBone) -> Iterable[bool]:
+        "Retrieve the lock status for 4D rotation."
+        if bone.lock_rotations_4d:
+            return [bone.lock_rotation_w, *bone.lock_rotation]
+        else:
+            return [all(bone.lock_rotation)] * 4
+
+    @staticmethod
+    def keyframe_channels(
+        target: Union[Object, PoseBone],
+        options: set[str],
+        data_path: str,
+        group: str,
+        locks: Iterable[bool],
+    ) -> None:
+        if all(locks):
+            return
+
+        if not any(locks):
+            target.keyframe_insert(data_path, group=group, options=options)
+            return
+
+        for index, lock in enumerate(locks):
+            if lock:
+                continue
+            target.keyframe_insert(data_path, index=index, group=group, options=options)
+
+    @classmethod
+    def key_transformation(
+        cls,
+        target: Union[Object, PoseBone],
+        options: set[str],
+    ) -> None:
+        """Keyframe transformation properties, avoiding keying locked channels."""
+
+        is_bone = isinstance(target, PoseBone)
+        if is_bone:
+            group = target.name
+        else:
+            group = "Object Transforms"
+
+        def keyframe(data_path: str, locks: Iterable[bool]) -> None:
+            cls.keyframe_channels(target, options, data_path, group, locks)
+
+        if not (is_bone and target.bone.use_connect):
+            keyframe("location", target.lock_location)
+
+        if target.rotation_mode == 'QUATERNION':
+            keyframe("rotation_quaternion", cls.get_4d_rotlock(target))
+        elif target.rotation_mode == 'AXIS_ANGLE':
+            keyframe("rotation_axis_angle", cls.get_4d_rotlock(target))
+        else:
+            keyframe("rotation_euler", target.lock_rotation)
+
+        keyframe("scale", target.lock_scale)
+
+    @classmethod
+    def autokey_transformation(cls, context: Context, target: Union[Object, PoseBone]) -> None:
+        """Auto-key transformation properties."""
+
+        options = cls.autokeying_options(context)
+        if options is None:
+            return
+        cls.key_transformation(target, options)
+
+
+def get_matrix(context: Context) -> Matrix:
+    bone = context.active_pose_bone
+    if bone:
+        # Convert matrix to world space
+        arm = context.active_object
+        mat = arm.matrix_world @ bone.matrix
+    else:
+        mat = context.active_object.matrix_world
+
+    return mat
+
+
+def set_matrix(context: Context, mat: Matrix) -> None:
+    bone = context.active_pose_bone
+    if bone:
+        # Convert matrix to local space
+        arm_eval = context.active_object.evaluated_get(context.view_layer.depsgraph)
+        bone.matrix = arm_eval.matrix_world.inverted() @ mat
+        AutoKeying.autokey_transformation(context, bone)
+    else:
+        context.active_object.matrix_world = mat
+        AutoKeying.autokey_transformation(context, context.active_object)
+
+
+def _selected_keyframes(context: Context) -> list[float]:
+    """Return the list of frame numbers that have a selected key.
+
+    Only keys on the active bone/object are considered.
+    """
+    bone = context.active_pose_bone
+    if bone:
+        return _selected_keyframes_for_bone(context.active_object, bone)
+    return _selected_keyframes_for_object(context.active_object)
+
+
+def _selected_keyframes_for_bone(object: Object, bone: PoseBone) -> list[float]:
+    """Return the list of frame numbers that have a selected key.
+
+    Only keys on the given pose bone are considered.
+    """
+    name = bpy.utils.escape_identifier(bone.name)
+    return _selected_keyframes_in_action(object, f'pose.bones["{name}"].')
+
+
+def _selected_keyframes_for_object(object: Object) -> list[float]:
+    """Return the list of frame numbers that have a selected key.
+
+    Only keys on the given object are considered.
+    """
+    return _selected_keyframes_in_action(object, "")
+
+
+def _selected_keyframes_in_action(object: Object, rna_path_prefix: str) -> list[float]:
+    """Return the list of frame numbers that have a selected key.
+
+    Only keys on the given object's Action on FCurves starting with rna_path_prefix are considered.
+    """
+
+    action = object.animation_data and object.animation_data.action
+    if action is None:
+        return []
+
+    keyframes = set()
+    for fcurve in action.fcurves:
+        if not fcurve.data_path.startswith(rna_path_prefix):
+            continue
+
+        for kp in fcurve.keyframe_points:
+            if not kp.select_control_point:
+                continue
+            keyframes.add(kp.co.x)
+    return sorted(keyframes)
+
+
+class OBJECT_OT_copy_global_transform(Operator):
+    bl_idname = "object.copy_global_transform"
+    bl_label = "Copy Global Transform"
+    bl_description = (
+        "Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
+    )
+    # This operator cannot be un-done because it manipulates data outside Blender.
+    bl_options = {'REGISTER'}
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bool(context.active_pose_bone) or bool(context.active_object)
+
+    def execute(self, context: Context) -> set[str]:
+        mat = get_matrix(context)
+        rows = [f"    {tuple(row)!r}," for row in mat]
+        as_string = "\n".join(rows)
+        context.window_manager.clipboard = f"Matrix((\n{as_string}\n))"
+        return {'FINISHED'}
+
+
+class OBJECT_OT_paste_transform(Operator):
+    bl_idname = "object.paste_transform"
+    bl_label = "Paste Global Transform"
+    bl_description = (
+        "Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
+    )
+    bl_options = {'REGISTER', 'UNDO'}
+
+    _method_items = [
+        (
+            'CURRENT',
+            "Current Transform",
+            "Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
+        ),
+        (
+            'EXISTING_KEYS',
+            "Selected Keys",
+            "Paste onto frames that have a selected key, potentially creating new keys on those frames",
+        ),
+        (
+            'BAKE',
+            "Bake on Key Range",
+            "Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
+        ),
+    ]
+    method: bpy.props.EnumProperty(  # type: ignore
+        items=_method_items,
+        name="Paste Method",
+        description="Update the current transform, selected keyframes, or even create new keys",
+    )
+    bake_step: bpy.props.IntProperty(  # type: ignore
+        name="Frame Step",
+        description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
+        min=1,
+        soft_min=1,
+        soft_max=5,
+    )
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        if not context.active_pose_bone and not context.active_object:
+            cls.poll_message_set("Select an object or pose bone")
+            return False
+        if not context.window_manager.clipboard.startswith("Matrix("):
+            cls.poll_message_set("Clipboard does not contain a valid matrix")
+            return False
+        return True
+
+    @staticmethod
+    def parse_print_m4(value: str) -> Optional[Matrix]:
+        """Parse output from Blender's print_m4() function.
+
+        Expects four lines of space-separated floats.
+        """
+
+        lines = value.strip().splitlines()
+        if len(lines) != 4:
+            return None
+
+        floats = tuple(tuple(float(item) for item in line.split()) for line in lines)
+        return Matrix(floats)
+
+    def execute(self, context: Context) -> set[str]:
+        clipboard = context.window_manager.clipboard
+        if clipboard.startswith("Matrix"):
+            mat = Matrix(ast.literal_eval(clipboard[6:]))
+        else:
+            mat = self.parse_print_m4(clipboard)
+
+        if mat is None:
+            self.report({'ERROR'}, "Clipboard does not contain a valid matrix.")
+            return {'CANCELLED'}
+
+        applicator = {
+            'CURRENT': self._paste_current,
+            'EXISTING_KEYS': self._paste_existing_keys,
+            'BAKE': self._paste_bake,
+        }[self.method]
+        return applicator(context, mat)
+
+    @staticmethod
+    def _paste_current(context: Context, matrix: Matrix) -> set[str]:
+        set_matrix(context, matrix)
+        return {'FINISHED'}
+
+    def _paste_existing_keys(self, context: Context, matrix: Matrix) -> set[str]:
+        if not context.scene.tool_settings.use_keyframe_insert_auto:
+            self.report({'ERROR'}, "This mode requires auto-keying to work properly")
+            return {'CANCELLED'}
+
+        frame_numbers = _selected_keyframes(context)
+        if not frame_numbers:
+            self.report({'WARNING'}, "No selected frames found")
+            return {'CANCELLED'}
+
+        self._paste_on_frames(context, frame_numbers, matrix)
+        return {'FINISHED'}
+
+    def _paste_bake(self, context: Context, matrix: Matrix) -> set[str]:
+        if not context.scene.tool_settings.use_keyframe_insert_auto:
+            self.report({'ERROR'}, "This mode requires auto-keying to work properly")
+            return {'CANCELLED'}
+
+        bake_step = max(1, self.bake_step)
+        # Put the clamped bake step back into RNA for the redo panel.
+        self.bake_step = bake_step
+
+        frame_start, frame_end = self._determine_bake_range(context)
+        frame_range = range(round(frame_start), round(frame_end) + bake_step, bake_step)
+        self._paste_on_frames(context, frame_range, matrix)
+        return {'FINISHED'}
+
+    def _determine_bake_range(self, context: Context) -> tuple[float, float]:
+        frame_numbers = _selected_keyframes(context)
+        if frame_numbers:
+            # Note that these could be the same frame, if len(frame_numbers) == 1:
+            return frame_numbers[0], frame_numbers[-1]
+
+        if context.scene.use_preview_range:
+            self.report({'INFO'}, "No selected keys, pasting over preview range")
+            return context.scene.frame_preview_start, context.scene.frame_preview_end
+
+        self.report({'INFO'}, "No selected keys, pasting over scene range")
+        return context.scene.frame_start, context.scene.frame_end
+
+    def _paste_on_frames(self, context: Context, frame_numbers: Iterable[float], matrix: Matrix) -> None:
+        current_frame = context.scene.frame_current_final
+        try:
+            for frame in frame_numbers:
+                context.scene.frame_set(int(frame), subframe=frame % 1.0)
+                set_matrix(context, matrix)
+        finally:
+            context.scene.frame_set(int(current_frame), subframe=current_frame % 1.0)
+
+
+class VIEW3D_PT_copy_global_transform(Panel):
+    bl_space_type = 'VIEW_3D'
+    bl_region_type = 'UI'
+    bl_category = "Animation"
+    bl_label = "Global Transform"
+
+    def draw(self, context: Context) -> None:
+        layout = self.layout
+
+        # No need to put "Global Transform" in the operator text, given that it's already in the panel title.
+        layout.operator("object.copy_global_transform", text="Copy", icon='COPYDOWN')
+
+        paste_col = layout.column(align=True)
+        paste_col.operator("object.paste_transform", text="Paste", icon='PASTEDOWN').method = 'CURRENT'
+        wants_autokey_col = paste_col.column(align=True)
+        has_autokey = context.scene.tool_settings.use_keyframe_insert_auto
+        wants_autokey_col.enabled = has_autokey
+        if not has_autokey:
+            wants_autokey_col.label(text="These require auto-key:")
+
+        wants_autokey_col.operator(
+            "object.paste_transform",
+            text="Paste to Selected Keys",
+            icon='PASTEDOWN',
+        ).method = 'EXISTING_KEYS'
+        wants_autokey_col.operator(
+            "object.paste_transform",
+            text="Paste and Bake",
+            icon='PASTEDOWN',
+        ).method = 'BAKE'
+
+
+### Messagebus subscription to monitor changes & refresh panels.
+_msgbus_owner = object()
+
+
+def _refresh_3d_panels():
+    refresh_area_types = {'VIEW_3D'}
+    for win in bpy.context.window_manager.windows:
+        for area in win.screen.areas:
+            if area.type not in refresh_area_types:
+                continue
+            area.tag_redraw()
+
+
+classes = (
+    OBJECT_OT_copy_global_transform,
+    OBJECT_OT_paste_transform,
+    VIEW3D_PT_copy_global_transform,
+)
+_register, _unregister = bpy.utils.register_classes_factory(classes)
+
+
+def _register_message_bus() -> None:
+    bpy.msgbus.subscribe_rna(
+        key=(bpy.types.ToolSettings, "use_keyframe_insert_auto"),
+        owner=_msgbus_owner,
+        args=(),
+        notify=_refresh_3d_panels,
+        options={'PERSISTENT'},
+    )
+
+
+def _unregister_message_bus() -> None:
+    bpy.msgbus.clear_by_owner(_msgbus_owner)
+
+
+@bpy.app.handlers.persistent  # type: ignore
+def _on_blendfile_load_post(none: Any, other_none: Any) -> None:
+    # The parameters are required, but both are None.
+    _register_message_bus()
+
+
+def register():
+    _register()
+    bpy.app.handlers.load_post.append(_on_blendfile_load_post)
+
+
+def unregister():
+    _unregister()
+    _unregister_message_bus()
+    bpy.app.handlers.load_post.remove(_on_blendfile_load_post)
-- 
GitLab