WIP: Animation: operators to update the pose library #104673

Draft
Sybren A. Stüvel wants to merge 10 commits from dr.sybren/blender-addons:pr/poselib-replace-pose into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
3 changed files with 166 additions and 17 deletions
Showing only changes of commit f1e7ff64dd - Show all commits

View File

@ -91,6 +91,7 @@ def pose_library_list_item_context_menu(self: UIList, context: Context) -> None:
layout.operator("poselib.blend_pose_asset", text="Blend Pose") layout.operator("poselib.blend_pose_asset", text="Blend Pose")
layout.operator("poselib.replace_pose_asset", text="Replace Pose") layout.operator("poselib.replace_pose_asset", text="Replace Pose")
layout.operator("poselib.update_pose_asset", text="Update Pose")
layout.separator() layout.separator()
props = layout.operator("poselib.pose_asset_select_bones", text="Select Pose Bones") props = layout.operator("poselib.pose_asset_select_bones", text="Select Pose Bones")

View File

@ -24,7 +24,7 @@ from bpy.props import BoolProperty, StringProperty
from bpy.types import ( from bpy.types import (
Action, Action,
Context, Context,
Event, FCurve,
FileSelectEntry, FileSelectEntry,
Object, Object,
Operator, Operator,
@ -204,6 +204,138 @@ class POSELIB_OT_replace_pose_asset(Operator):
asset_browser.activate_asset(asset, asset_browse_area, deferred=True) 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:
if not isinstance(getattr(context, "id", None), Action):
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
asset_space_params = asset_browser.params(context.area)
if asset_space_params.asset_library_ref != 'LOCAL':
cls.poll_message_set("Asset Browser must be set to the Current File library")
return False
return True
def execute(self, context: Context) -> Set[str]:
# Make sure the new keys are on the same frame as the old keys.
storage_frame_nr = self._find_frame_number(context)
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:
original_pose: Action = context.id
was_updated = self._update_pose_asset(original_pose, new_pose, storage_frame_nr)
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,
original_pose: Action,
new_pose: Action,
storage_frame_nr: float,) -> bool:
"""Perform the pose update.
:return: True if the original pose 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 original_pose.fcurves
}
num_fcurves_added = 0
num_fcurves_edited = 0
for fcu in new_pose.fcurves:
fcu_value = fcu.keyframe_points[0].co.y
try:
orig_fcu = original_fcurve_lookup[fcu.data_path, fcu.array_index]
except KeyError:
orig_fcu = None
if orig_fcu is None:
# No such FCurve, so create a new one.
group_name = getattr(fcu.group, 'name', '')
new_fcu = original_pose.fcurves.new(
fcu.data_path,
index=fcu.array_index,
action_group=group_name,
)
new_fcu.keyframe_points.insert(storage_frame_nr, value=fcu_value)
new_fcu.update()
num_fcurves_added += 1
continue
# Update the exsting FCurve. This assumes that it is a valid
# pose asset, i.e. that it has a single key.
kp = orig_fcu.keyframe_points[0]
old_value = kp.co.y
# Don't bother updating the FCurve if the value was unchanged.
if abs(old_value - fcu_value) < 0.0001:
continue
# Use co_ui to move the handles along with the value.
kp.co_ui.y = fcu_value
orig_fcu.update()
num_fcurves_edited += 1
if num_fcurves_added == 0 and num_fcurves_edited == 0:
self.report({'WARNING'}, "Pose did not change, so the asset was not updated")
return False
original_pose.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(context: Context) -> float:
original_pose: Action = context.id
assert isinstance(original_pose, Action), f"expected an Action, got {original_pose}"
dr.sybren marked this conversation as resolved
Review

Unimportant nit: it doesn't seem like the old_value temporary is necessary.

(fcu_value, by contrast, I think aids readability, since it's short-handing something that's rather long to read.)

Not a big deal either way, though.

Unimportant nit: it doesn't seem like the `old_value` temporary is necessary. (`fcu_value`, by contrast, I think aids readability, since it's short-handing something that's rather long to read.) Not a big deal either way, though.

It's indeed not really necessary, but to me it makes the if old_value == fcu_value: read nicer. That can be resolved by renaming kp to keyframe and it overall being less cryptic.

It's indeed not really necessary, but to me it makes the `if old_value == fcu_value:` read nicer. That can be resolved by renaming `kp` to `keyframe` and it overall being less cryptic.
if original_pose.fcurves and original_pose.fcurves[0].keyframe_points:
return original_pose.fcurves[0].keyframe_points[0].co.x
return context.scene.frame_current
dr.sybren marked this conversation as resolved
Review

Do we expect there to be floating point error involved here? I wouldn't think so, but I could very well be missing something.

But if not, I think it makes more sense to just do a straight == comparison, rather than having an arbitrary epsilon.

Do we expect there to be floating point error involved here? I wouldn't think so, but I could very well be missing something. But if not, I think it makes more sense to just do a straight `==` comparison, rather than having an arbitrary epsilon.
class POSELIB_OT_restore_previous_action(Operator): class POSELIB_OT_restore_previous_action(Operator):
bl_idname = "poselib.restore_previous_action" bl_idname = "poselib.restore_previous_action"
bl_label = "Restore Previous Action" bl_label = "Restore Previous Action"
@ -516,6 +648,7 @@ classes = (
POSELIB_OT_copy_as_asset, POSELIB_OT_copy_as_asset,
POSELIB_OT_create_pose_asset, POSELIB_OT_create_pose_asset,
POSELIB_OT_replace_pose_asset, POSELIB_OT_replace_pose_asset,
POSELIB_OT_update_pose_asset,
POSELIB_OT_paste_asset, POSELIB_OT_paste_asset,
POSELIB_OT_pose_asset_select_bones, POSELIB_OT_pose_asset_select_bones,
POSELIB_OT_restore_previous_action, POSELIB_OT_restore_previous_action,

View File

@ -36,8 +36,9 @@ pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
@dataclasses.dataclass(unsafe_hash=True, frozen=True) @dataclasses.dataclass(unsafe_hash=True, frozen=True)
class PoseCreationParams: class PoseCreationParams:
armature_ob: bpy.types.Object 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 src_frame_nr: float
dest_frame_nr: float
bone_names: FrozenSet[str] bone_names: FrozenSet[str]
new_asset_name: str new_asset_name: str
@ -130,7 +131,7 @@ class PoseActionCreator:
continue continue
# Only include in the pose if there is a key on this frame. # 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 continue
try: try:
@ -140,7 +141,7 @@ class PoseActionCreator:
continue continue
dst_fcurve = dst_action.fcurves.new(fcurve.data_path, index=fcurve.array_index, action_group=bone_name) 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() dst_fcurve.update()
def _store_location(self, dst_action: Action, bone_name: str) -> None: def _store_location(self, dst_action: Action, bone_name: str) -> None:
@ -193,7 +194,7 @@ class PoseActionCreator:
if fcurve is None: if fcurve is None:
fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name) 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() fcurve.update()
@classmethod @classmethod
@ -244,20 +245,20 @@ class PoseActionCreator:
bone: Bone = self.params.armature_ob.pose.bones[bone_name] bone: Bone = self.params.armature_ob.pose.bones[bone_name]
return bone return bone
def _has_key_on_frame(self, fcurve: FCurve) -> bool: @staticmethod
"""Return True iff the FCurve has a key on the source frame.""" 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 points = fcurve.keyframe_points
if not points: if not points:
return False return False
frame_to_find = self.params.src_frame_nr
margin = 0.001 margin = 0.001
high = len(points) - 1 high = len(points) - 1
low = 0 low = 0
while low <= high: while low <= high:
mid = (high + low) // 2 mid = (high + low) // 2
diff = points[mid].co.x - frame_to_find diff = points[mid].co.x - frame_nr
if abs(diff) < margin: if abs(diff) < margin:
return True return True
if diff < 0: 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]: def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]:
"""Create Action asset from active object & selected bones.""" """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 bones = context.selected_pose_bones_from_active_object
bone_names = {bone.name for bone in bones} bone_names = {bone.name for bone in bones}
params = PoseCreationParams( if dest_frame_nr is None:
context.object, dest_frame_nr = context.scene.frame_current
getattr(context.object.animation_data, "action", None),
context.scene.frame_current,
frozenset(bone_names),
new_asset_name,
)
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( def copy_fcurves(