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