diff --git a/pose_library/gui.py b/pose_library/gui.py index 8f0360780..0f9fc32c4 100644 --- a/pose_library/gui.py +++ b/pose_library/gui.py @@ -96,6 +96,10 @@ def pose_library_list_item_context_menu(self: UIList, context: Context) -> None: props = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones") props.select = False + layout.separator() + layout.operator("poselib.update_pose_asset", text="Update Pose Asset") + layout.operator("poselib.replace_pose_asset", text="Replace Pose Asset") + if not is_pose_asset_view(): layout.separator() layout.operator("asset.assign_action") @@ -147,7 +151,7 @@ class ASSETBROWSER_MT_asset(Menu): layout.operator("poselib.create_pose_asset").activate_new_action = False -### Messagebus subscription to monitor asset library changes. +# Messagebus subscription to monitor asset library changes. _msgbus_owner = object() diff --git a/pose_library/operators.py b/pose_library/operators.py index 207204d38..303b9327f 100644 --- a/pose_library/operators.py +++ b/pose_library/operators.py @@ -24,7 +24,7 @@ from bpy.props import BoolProperty, StringProperty from bpy.types import ( Action, Context, - Event, + FCurve, FileSelectEntry, Object, Operator, @@ -139,6 +139,209 @@ class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator): self.report({'WARNING'}, tip_("Action %s marked Fake User to prevent loss") % action.name) +def _get_action(context: Context) -> Action | None: + """Get the current action from the asset browser or the asset view.""" + + if context.asset_library_ref and context.asset_file_handle and context.asset_file_handle.id_type == 'ACTION': + context_id = context.asset_file_handle.local_id + else: + context_id = getattr(context, "id", None) + + if not isinstance(context_id, Action): + return None + + return context_id + + +class POSELIB_OT_replace_pose_asset(Operator): + bl_idname = "poselib.replace_pose_asset" + bl_label = "Replace Pose Asset" + bl_description = ( + "Create a new Action that contains the pose of the selected bones, and use that to replace the selected Asset" + ) + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + if not _get_action(context): + cls.poll_message_set("Select a pose asset from the currently open blend file") + return False + + if context.object is None or context.object.mode != "POSE": + # The operator assumes pose mode, so that bone selection is visible. + cls.poll_message_set("An active armature object in pose mode is needed") + return False + + return True + + def execute(self, context: Context) -> Set[str]: + original_pose: Action = _get_action(context) + + pose_name = original_pose.name # This will not use this name entirely, but it'll be made unique. + asset = pose_creation.create_pose_asset_from_context(context, pose_name) + if not asset: + self.report({"WARNING"}, "No keyframes were found for this pose") + return {"CANCELLED"} + + # Copy the asset metadata to the newly created asset. + asset.asset_data = original_pose.asset_data + + self._activate_asset_in_browser(context, asset) + + # Delete the old asset + bpy.data.actions.remove(original_pose) + # Now that the old name has become available, rename the asset to it. + asset.name = pose_name + + return {'FINISHED'} + + def _activate_asset_in_browser(self, context: Context, asset: Action) -> None: + """Activate the new asset in the appropriate Asset Browser. + + This makes it possible to immediately check & edit the created pose asset. + """ + + asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context) + if not asset_browse_area: + return + + # After creating an asset, the window manager has to process the + # notifiers before editors should be manipulated. + pose_creation.assign_from_asset_browser(asset, asset_browse_area) + + # Pass deferred=True, because we just created a new asset that isn't + # known to the Asset Browser space yet. That requires the processing of + # notifiers, which will only happen after this code has finished + # running. + asset_browser.activate_asset(asset, asset_browse_area, deferred=True) + + +class POSELIB_OT_update_pose_asset(Operator): + bl_idname = "poselib.update_pose_asset" + bl_label = "Update Pose Asset" + bl_description = ( + "Update the selected pose asset with the selected bones, leaving the unselected bone channels as-is" + ) + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + return POSELIB_OT_replace_pose_asset(context) + + def execute(self, context: Context) -> Set[str]: + # Make sure the new keys are on the same frame as the old keys. + original_pose: Action = _get_action(context) + storage_frame_nr = self._find_frame_number(original_pose, context.scene.frame_current) + + params = pose_creation.params_for_selected_bones( + context, + "temporary-pose-action", + dest_frame_nr=storage_frame_nr, + ) + + # Create a temporary pose Action. This will be used to update the existing pose asset. + creator = pose_creation.PoseActionCreator(params) + new_pose = creator.create() + if new_pose is None: + self.report({"WARNING"}, "Select the to-be-updated bones") + return {"CANCELLED"} + + try: + was_updated = self._update_pose_asset(original_pose, new_pose) + finally: + # Clean up by removing the temporary pose Action. + bpy.data.actions.remove(new_pose) + + return {'FINISHED'} if was_updated else {'CANCELLED'} + + def _update_pose_asset(self, + pose_to_update: Action, + update_source: Action) -> bool: + """Copy keys from `update_source` to `pose_to_update`. + + :return: True if `pose_to_update` was altered, False otherwise. + """ + + # Create lookup table for FCurves. There's also Action.fcurves.find() but + # that would do a linear search through all FCurves in the action, for + # each lookup. + original_fcurve_lookup: dict[tuple[str, int], FCurve] = { + (fcu.data_path, fcu.array_index): fcu + for fcu in pose_to_update.fcurves + } + + num_fcurves_added = 0 + num_fcurves_edited = 0 + for fcu in update_source.fcurves: + try: + orig_fcu = original_fcurve_lookup[fcu.data_path, fcu.array_index] + except KeyError: + orig_fcu = None + + if orig_fcu is None: + self._create_new_fcurve(pose_to_update, fcu) + num_fcurves_added += 1 + continue + + was_updated = self._update_existing_fcurve(pose_to_update, orig_fcu, fcu) + if was_updated: + num_fcurves_edited += 1 + + if not any((num_fcurves_added, num_fcurves_edited)): + self.report({'WARNING'}, "Pose did not change, so the asset was not updated") + return False + + pose_to_update.asset_generate_preview() + + # Present the results. + msg_parts = [] + if num_fcurves_added: + msg_parts.append("%d FCurves added" % num_fcurves_added) + if num_fcurves_edited: + msg_parts.append("%d FCurves edited" % num_fcurves_edited) + msg = ", ".join(msg_parts) + self.report({'INFO'}, "Updated pose: %s" % msg) + + return True + + @staticmethod + def _find_frame_number(action: Action, fallback_frame: float) -> float: + assert isinstance(action, Action), f"expected an Action, got {action}" + if action.fcurves and action.fcurves[0].keyframe_points: + return action.fcurves[0].keyframe_points[0].co.x + return fallback_frame + + @staticmethod + def _create_new_fcurve(action: Action, fcu_to_copy: FCurve) -> None: + group_name = getattr(fcu_to_copy.group, 'name', '') + + new_fcu = action.fcurves.new( + fcu_to_copy.data_path, + index=fcu_to_copy.array_index, + action_group=group_name, + ) + + keyframe_co = fcu_to_copy.keyframe_points[0].co + new_fcu.keyframe_points.insert(keyframe_co.x, value=keyframe_co.y) + new_fcu.update() + + @staticmethod + def _update_existing_fcurve(action: Action, fcu_existing: FCurve, fcu_to_read: FCurve) -> bool: + # Update the exsting FCurve. This assumes that it is a valid + # pose asset, i.e. that the FCurve has a single key. + keyframe = fcu_existing.keyframe_points[0] + + # Don't bother updating the FCurve if the value was unchanged. + fcu_value = fcu_to_read.keyframe_points[0].co.y + if keyframe.co.y == fcu_value: + return False + + # Use co_ui to move the handles along with the value. + keyframe.co_ui.y = fcu_value + fcu_existing.update() + return True + + class POSELIB_OT_restore_previous_action(Operator): bl_idname = "poselib.restore_previous_action" bl_label = "Restore Previous Action" @@ -449,6 +652,8 @@ classes = ( POSELIB_OT_convert_old_object_poselib, POSELIB_OT_copy_as_asset, POSELIB_OT_create_pose_asset, + POSELIB_OT_replace_pose_asset, + POSELIB_OT_update_pose_asset, POSELIB_OT_paste_asset, POSELIB_OT_pose_asset_select_bones, POSELIB_OT_restore_previous_action, diff --git a/pose_library/pose_creation.py b/pose_library/pose_creation.py index e1faf39cc..1fe3cd940 100644 --- a/pose_library/pose_creation.py +++ b/pose_library/pose_creation.py @@ -36,8 +36,9 @@ pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]') @dataclasses.dataclass(unsafe_hash=True, frozen=True) class PoseCreationParams: armature_ob: bpy.types.Object - src_action: Optional[Action] + src_action: Optional[Action] # For copying non-transform properties when they have been keyed. src_frame_nr: float + dest_frame_nr: float bone_names: FrozenSet[str] new_asset_name: str @@ -130,7 +131,7 @@ class PoseActionCreator: continue # Only include in the pose if there is a key on this frame. - if not self._has_key_on_frame(fcurve): + if not self._has_key_on_frame(fcurve, self.params.src_frame_nr): continue try: @@ -140,7 +141,7 @@ class PoseActionCreator: continue dst_fcurve = dst_action.fcurves.new(fcurve.data_path, index=fcurve.array_index, action_group=bone_name) - dst_fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value) + dst_fcurve.keyframe_points.insert(self.params.dest_frame_nr, value=value) dst_fcurve.update() def _store_location(self, dst_action: Action, bone_name: str) -> None: @@ -193,7 +194,7 @@ class PoseActionCreator: if fcurve is None: fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name) - fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value) + fcurve.keyframe_points.insert(self.params.dest_frame_nr, value=value) fcurve.update() @classmethod @@ -244,20 +245,20 @@ class PoseActionCreator: bone: Bone = self.params.armature_ob.pose.bones[bone_name] return bone - def _has_key_on_frame(self, fcurve: FCurve) -> bool: - """Return True iff the FCurve has a key on the source frame.""" + @staticmethod + def _has_key_on_frame(fcurve: FCurve, frame_nr: float) -> bool: + """Return True iff the FCurve has a key on the given frame.""" points = fcurve.keyframe_points if not points: return False - frame_to_find = self.params.src_frame_nr margin = 0.001 high = len(points) - 1 low = 0 while low <= high: mid = (high + low) // 2 - diff = points[mid].co.x - frame_to_find + diff = points[mid].co.x - frame_nr if abs(diff) < margin: return True if diff < 0: @@ -290,18 +291,32 @@ def create_pose_asset( def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]: """Create Action asset from active object & selected bones.""" + params = params_for_selected_bones(context, new_asset_name) + return create_pose_asset(params) + + +def params_for_selected_bones( + context: Context, + new_asset_name: str, + dest_frame_nr: float | None = None, +) -> PoseCreationParams: + """Return suitable pose asset creation parameters for the selected pose bones.""" + bones = context.selected_pose_bones_from_active_object bone_names = {bone.name for bone in bones} - params = PoseCreationParams( - context.object, - getattr(context.object.animation_data, "action", None), - context.scene.frame_current, - frozenset(bone_names), - new_asset_name, - ) + if dest_frame_nr is None: + dest_frame_nr = context.scene.frame_current - return create_pose_asset(params) + params = PoseCreationParams( + armature_ob=context.object, + src_action=getattr(context.object.animation_data, "action", None), + src_frame_nr=context.scene.frame_current, + dest_frame_nr=dest_frame_nr, + bone_names=frozenset(bone_names), + new_asset_name=new_asset_name, + ) + return params def copy_fcurves(