WIP: Animation: operators to update the pose library #104673
@ -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 = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones")
|
||||||
props.select = False
|
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():
|
if not is_pose_asset_view():
|
||||||
layout.separator()
|
layout.separator()
|
||||||
layout.operator("asset.assign_action")
|
layout.operator("asset.assign_action")
|
||||||
@ -147,7 +151,7 @@ class ASSETBROWSER_MT_asset(Menu):
|
|||||||
layout.operator("poselib.create_pose_asset").activate_new_action = False
|
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()
|
_msgbus_owner = object()
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,
|
||||||
@ -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)
|
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]
|
||||||
|
|
||||||
dr.sybren marked this conversation as resolved
|
|||||||
|
# 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
|
||||||
dr.sybren marked this conversation as resolved
Nathan Vegdahl
commented
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 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.
|
|||||||
|
|
||||||
|
# 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):
|
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"
|
||||||
@ -449,6 +652,8 @@ classes = (
|
|||||||
POSELIB_OT_convert_old_object_poselib,
|
POSELIB_OT_convert_old_object_poselib,
|
||||||
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_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,
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user
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 renamingkp
tokeyframe
and it overall being less cryptic.