diff --git a/scripts/addons_core/rigify/__init__.py b/scripts/addons_core/rigify/__init__.py index a080dceb0ae..0a273b01008 100644 --- a/scripts/addons_core/rigify/__init__.py +++ b/scripts/addons_core/rigify/__init__.py @@ -859,6 +859,10 @@ def register_rna_properties() -> None: name="Rigify Owner Rig", description="Rig that owns this object and may delete or overwrite it upon re-generation") + # 5.0: Version metarigs to new Action Slot selector properties on file load. + from .utils.action_layers import versioning_5_0 + bpy.app.handlers.load_post.append(versioning_5_0) + def unregister_rna_properties() -> None: # Properties on PoseBones and Armature. (Annotated to suppress unknown attribute warnings.) diff --git a/scripts/addons_core/rigify/generate.py b/scripts/addons_core/rigify/generate.py index bfc4b530b52..71654fd2077 100644 --- a/scripts/addons_core/rigify/generate.py +++ b/scripts/addons_core/rigify/generate.py @@ -291,6 +291,10 @@ class Generator(base_generate.BaseGenerator): tar.data_path = "RIGIFY-" + tar.data_path def __rename_org_bones(self, obj: ArmatureObject): + # Clear any assigned Action, so we don't rename fcurves when renaming ORG- bones. + if obj.animation_data: + obj.animation_data.action = None + # Make a list of the original bones, so we can keep track of them. original_bones = [bone.name for bone in obj.data.bones] diff --git a/scripts/addons_core/rigify/operators/action_layers.py b/scripts/addons_core/rigify/operators/action_layers.py index 6c07c05978f..1df2a76ca89 100644 --- a/scripts/addons_core/rigify/operators/action_layers.py +++ b/scripts/addons_core/rigify/operators/action_layers.py @@ -3,10 +3,11 @@ # SPDX-License-Identifier: GPL-2.0-or-later import bpy +import random -from typing import Tuple, Optional, Sequence, Any +from typing import Sequence, Any -from bpy.types import PropertyGroup, Action, UIList, UILayout, Context, Panel, Operator, Armature +from bpy.types import PropertyGroup, Action, UIList, UILayout, Context, Panel, Operator, Armature, ActionSlot from bpy.props import (EnumProperty, IntProperty, BoolProperty, StringProperty, FloatProperty, PointerProperty, CollectionProperty) from bpy.app.translations import ( @@ -50,13 +51,115 @@ def poll_trigger_action(_self, action): return False +def get_first_compatible_action_slot(action: Action, id_type: str) -> ActionSlot | None: + for slot in action.slots: + if slot.target_id_type in ('UNSPECIFIED', id_type): + return slot + return None + + class ActionSlot(PropertyGroup, ActionSlotBase): + def on_action_update(self, context): + if not self.action: + return + + # We must trigger the lazy-initialization of the unique_id before + # any UI code tries to access it, since if it tried to lazy-initialize + # during UI drawing, that would result in an error. + self.ensure_unique_id() + + if self.action_slot: + # Nothing else to do here. + return + + # Set the first compatible slot if none already set. However, be careful + # to prevent infinite loops, as this will call this function again. + first_slot = get_first_compatible_action_slot(self.action, 'OBJECT') + if first_slot: + # Only write when not None, to prevent looping infinitely. + self.action_slot = first_slot + action: PointerProperty( name="Action", type=Action, - description="Action to apply to the rig via constraints" + description="Action to apply to the rig via constraints", + update=on_action_update, ) + def slot_name_from_handle(self, slot_handle_as_str: str, _is_set: bool) -> str: + """This is a get_transform callback function, see Blender 5.0 PyAPI docs.""" + if not slot_handle_as_str: + return "" + slot_handle = int(slot_handle_as_str) + action_slot = next((s for s in self.action.slots if s.handle == slot_handle), None) + if not action_slot: + return "" + # We use the display name rather than the identifier because in Rigify's context, + # we don't care about the datablock type prefix found in the identifier, + # since our action slots are always for Objects. + return action_slot.name_display + + def slot_name_to_handle(self, new_name: str, _current_name: str, _is_set: bool) -> str: + """This is a set_transform callback function, see Blender 5.0 PyAPI docs.""" + action_slot = self.action.slots.get("OB" + new_name) + if not action_slot: + return "" + return str(action_slot.handle) + + action_slot_ui: StringProperty( + name="Acion Slot", + description="Slot of the Action to use for the Action Constraints", + # These callbacks let us store the action slot's `handle` property + # under the hood (which is unique and never changes), while acting + # as a user-friendly display name in the UI. + get_transform=slot_name_from_handle, + set_transform=slot_name_to_handle, + update=on_action_update, + ) + + unique_id: IntProperty(default=0) + + def ensure_unique_id(self) -> int: + if self.unique_id: + return self.unique_id + + # IDProperties only support signed 32-bit integers, so this is the + # biggest pool of random numbers we can pick from. + unique_id = random.randint(0, 2**31 - 1) + self.unique_id = unique_id + return unique_id + + @property + def action_slot(self) -> ActionSlot | None: + return self.action.slots.get("OB" + self.action_slot_ui) + + @action_slot.setter + def action_slot(self, slot: ActionSlot): + """For convenience, caller can assign an Action Slot, + even though under the hood we'll actually be storing the slot handle. + """ + # We don't actually assign the handle directly, since we have + # the action_slot_ui wrapper property, which masks the handle for us. + self.action_slot_ui = slot.name_display if slot else "" + + def get_name_transform(self) -> str: + """Return a useful display name for this Rigify action set-up, + consisting of the Action name and the slot name, with a little arrow inbetween. + + The latter is omitted when the Action has only a single slot, to be less cluttered + for users who prefer to use separate Actions, + and for legacy rigs where all slots are named "Legacy Slot". + """ + if not self.action: + return str(self.unique_id) + + if self.action_slot and len(self.action.slots) > 1: + return f"{self.action.name} ➔ {self.action_slot.name_display}" + + return self.action.name + + name: StringProperty(get=get_name_transform) + enabled: BoolProperty( name="Enabled", description="Create constraints for this action on the generated rig", @@ -135,6 +238,8 @@ class ActionSlot(PropertyGroup, ActionSlotBase): "to the last frame. Rotations are in degrees" ) + # Corrective Action properties + is_corrective: BoolProperty( name="Corrective", description="Indicate that this is a corrective action. Corrective actions will activate " @@ -142,46 +247,63 @@ class ActionSlot(PropertyGroup, ActionSlotBase): "are at their End Frame, and Start Frame if either is at Start Frame)" ) - trigger_action_a: PointerProperty( + def setup_id_to_str(self, unique_id_as_str: str, _is_set: bool) -> str: + """This is a get_transform callback function, see Blender 5.0 PyAPI docs.""" + if not unique_id_as_str: + return "" + unique_id = int(unique_id_as_str) + action_setups = self.id_data.rigify_action_slots + action_setup = next((setup for setup in action_setups if setup.unique_id == unique_id), None) + if not action_setup: + return "" + return action_setup.name + + def setup_name_to_id(self, name: str, _curr_value: str, _is_set: bool) -> str: + """This is a set_transform callback function, see Blender 5.0 PyAPI docs.""" + action_setups = self.id_data.rigify_action_slots + action_setup = next((setup for setup in action_setups if setup.name == name), None) + if not action_setup: + return "" + return str(action_setup.unique_id) + + trigger_select_a: StringProperty( name="Trigger A", - type=Action, - description="Action whose activation will trigger the corrective action", - poll=poll_trigger_action + description="Action Set-up whose activation will trigger this set-up as a corrective", + get_transform=setup_id_to_str, + set_transform=setup_name_to_id, + ) + trigger_select_b: StringProperty( + name="Trigger B", + description="Action Set-up whose activation will trigger this set-up as a corrective", + # These callbacks let us store the trigger action setups' `unique_id` property + # under the hood (which is unique and never changes), while acting as + # a user-friendly display name in the UI. + get_transform=setup_id_to_str, + set_transform=setup_name_to_id, ) - trigger_action_b: PointerProperty( - name="Trigger B", - description="Action whose activation will trigger the corrective action", - type=Action, - poll=poll_trigger_action - ) + @property + def trigger_a(self): + action_setups = self.id_data.rigify_action_slots + return next((setup for setup in action_setups if setup.name == self.trigger_select_a), None) + + @trigger_a.setter + def trigger_a(self, action_setup): + self.trigger_select_a = action_setup.name if action_setup else "" + + @property + def trigger_b(self): + action_setups = self.id_data.rigify_action_slots + return next((setup for setup in action_setups if setup.name == self.trigger_select_b), None) + + @trigger_b.setter + def trigger_b(self, action_setup): + self.trigger_select_b = action_setup.name if action_setup else "" show_action_a: BoolProperty(name="Show Settings") show_action_b: BoolProperty(name="Show Settings") -def find_slot_by_action(metarig_data: Armature, action) -> Tuple[Optional[ActionSlot], int]: - """Find the ActionSlot in the rig which targets this action.""" - if not action: - return None, -1 - - for i, slot in enumerate(get_action_slots(metarig_data)): - if slot.action == action: - return slot, i - else: - return None, -1 - - -def find_duplicate_slot(metarig_data: Armature, action_slot: ActionSlot) -> Optional[ActionSlot]: - """Find a different ActionSlot in the rig which has the same action.""" - - for slot in get_action_slots(metarig_data): - if slot.action == action_slot.action and slot != action_slot: - return slot - - return None - - # ============================================= # Operators @@ -213,11 +335,19 @@ class RIGIFY_OT_jump_to_action_slot(Operator): bl_label = "Jump to Action Slot" bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} - to_index: IntProperty() + to_unique_id: IntProperty() def execute(self, context): armature_id_store = context.object.data - armature_id_store.rigify_active_action_slot = self.to_index + for i, action_setup in enumerate(armature_id_store.rigify_action_slots): + if action_setup.unique_id == self.to_unique_id: + armature_id_store.rigify_active_action_slot = i + break + else: + self.report({'ERROR'}, "Failed to find Action Slot.") + return {'CANCELLED'} + + self.report({'INFO'}, f'Set active action set-up index to {i}.') return {'FINISHED'} @@ -238,35 +368,26 @@ class RIGIFY_UL_action_slots(UIList): # Check if this action is a trigger for the active corrective action if active_action.is_corrective and \ - action_slot.action in [active_action.trigger_action_a, - active_action.trigger_action_b]: + action_slot in (active_action.trigger_a, + active_action.trigger_b): icon = 'RESTRICT_INSTANCED_OFF' # Check if the active action is a trigger for this corrective action. if action_slot.is_corrective and \ - active_action.action in [action_slot.trigger_action_a, - action_slot.trigger_action_b]: + active_action in [action_slot.trigger_a, + action_slot.trigger_b]: icon = 'RESTRICT_INSTANCED_OFF' - row.prop(action_slot.action, 'name', text="", emboss=False, icon=icon) + row.label(text=action_slot.name, icon=icon) # Highlight various errors - - if find_duplicate_slot(data, action_slot): - # Multiple entries for the same action - row.alert = True - row.label(text="Duplicate", icon='ERROR') - - elif action_slot.is_corrective: + if action_slot.is_corrective: text = "Corrective" icon = 'RESTRICT_INSTANCED_OFF' - for trigger in [action_slot.trigger_action_a, - action_slot.trigger_action_b]: - trigger_slot, trigger_idx = find_slot_by_action(data, trigger) - + for trigger in (action_slot.trigger_a, action_slot.trigger_b): # No trigger action set, no slot or invalid slot - if not trigger_slot or trigger_slot.is_corrective: + if not trigger or trigger.is_corrective: row.alert = True text = "No Trigger Action" icon = 'ERROR' @@ -311,7 +432,6 @@ class RIGIFY_UL_action_slots(UIList): layout.label(text="", translate=False, icon='ACTION') - # noinspection PyPep8Naming class DATA_PT_rigify_actions(Panel): bl_space_type = 'PROPERTIES' @@ -344,22 +464,28 @@ class DATA_PT_rigify_actions(Panel): if len(action_slots) == 0: return - active_slot = action_slots[active_idx] + active_action_setup = action_slots[active_idx] - layout.template_ID(active_slot, 'action', new=RIGIFY_OT_action_create.bl_idname) - - if not active_slot.action: + col = layout.column(align=True) + col.template_ID(active_action_setup, 'action', new=RIGIFY_OT_action_create.bl_idname) + if not active_action_setup.action: + return + if not active_action_setup.action.slots: + layout.alert = True + layout.label(text="No slots in this Action.") return - layout = layout.column() - layout.prop(active_slot, 'is_corrective') + col.prop_search(active_action_setup, "action_slot_ui", active_action_setup.action, 'slots', text="") - if active_slot.is_corrective: - self.draw_ui_corrective(context, active_slot) + layout = layout.column() + layout.prop(active_action_setup, 'is_corrective') + + if active_action_setup.is_corrective: + self.draw_ui_corrective(context, active_action_setup) else: target_rig = get_rigify_target_rig(armature_id_store) - self.draw_slot_ui(layout, active_slot, target_rig) - self.draw_status(active_slot) + self.draw_slot_ui(layout, active_action_setup, target_rig) + self.draw_status(active_action_setup) def draw_ui_corrective(self, context: Context, slot): layout = self.layout @@ -368,7 +494,7 @@ class DATA_PT_rigify_actions(Panel): layout.prop(slot, 'frame_end', text="End") layout.separator() - for trigger_prop in ['trigger_action_a', 'trigger_action_b']: + for trigger_prop in ['trigger_select_a', 'trigger_select_b']: self.draw_ui_trigger(context, slot, trigger_prop) def draw_ui_trigger(self, context: Context, slot, trigger_prop: str): @@ -376,18 +502,17 @@ class DATA_PT_rigify_actions(Panel): metarig = context.object assert isinstance(metarig.data, Armature) - trigger = getattr(slot, trigger_prop) + trigger = getattr(slot, trigger_prop.replace("select_", "")) icon = 'ACTION' if trigger else 'ERROR' row = layout.row() - row.prop(slot, trigger_prop, icon=icon) + try: + active_slot = metarig.data.rigify_action_slots[metarig.data.rigify_active_action_slot] + except IndexError: + return + row.prop_search(active_slot, trigger_prop, metarig.data, 'rigify_action_slots', icon=icon) if not trigger: - return - - trigger_slot, slot_index = find_slot_by_action(metarig.data, trigger) - - if not trigger_slot: row = layout.split(factor=0.4) row.separator() row.alert = True @@ -401,13 +526,13 @@ class DATA_PT_rigify_actions(Panel): row.prop(slot, show_prop_name, icon=icon, text="") op = row.operator(RIGIFY_OT_jump_to_action_slot.bl_idname, text="", icon='LOOP_FORWARDS') - op.to_index = slot_index + op.to_unique_id = trigger.unique_id if show: col = layout.column(align=True) col.enabled = False target_rig = get_rigify_target_rig(metarig.data) - self.draw_slot_ui(col, trigger_slot, target_rig) + self.draw_slot_ui(col, trigger, target_rig) col.separator() @staticmethod diff --git a/scripts/addons_core/rigify/utils/action_layers.py b/scripts/addons_core/rigify/utils/action_layers.py index cb6c42e7032..31810d7ca7f 100644 --- a/scripts/addons_core/rigify/utils/action_layers.py +++ b/scripts/addons_core/rigify/utils/action_layers.py @@ -2,8 +2,12 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +from __future__ import annotations +import bpy from typing import Optional, List, Dict, Tuple, TYPE_CHECKING -from bpy.types import Action, Mesh, Armature +from bpy.types import Action, Mesh, Armature, ActionChannelbag +from bpy.types import ActionSlot as BlenderActionSlot + from bl_math import clamp from bpy_extras import anim_utils @@ -28,6 +32,7 @@ class ActionSlotBase: """Abstract non-RNA base for the action list slots.""" action: Optional[Action] + action_slot: Optional[BlenderActionSlot] enabled: bool symmetrical: bool subtarget: str @@ -38,8 +43,8 @@ class ActionSlotBase: trans_min: float trans_max: float is_corrective: bool - trigger_action_a: Optional[Action] - trigger_action_b: Optional[Action] + trigger_a: Optional[ActionSlotBase] + trigger_b: Optional[ActionSlotBase] ############################################ # Action Constraint Setup @@ -49,10 +54,7 @@ class ActionSlotBase: """Return a list of bone names that have keyframes in the Action of this Slot.""" keyed_bones = [] - # This will get updated to actually support slots properly in #146182. For - # now, just pick the first suitable action slot. - action_slot = anim_utils.action_get_first_suitable_slot(self.action, 'OBJECT') - channelbag = anim_utils.action_get_channelbag_for_slot(self.action, action_slot) + channelbag = anim_utils.action_get_channelbag_for_slot(self.action, self.action_slot) if not channelbag: return [] @@ -150,7 +152,7 @@ class GeneratedActionSlot(ActionSlotBase): def __init__(self, action, *, enabled=True, symmetrical=True, subtarget='', transform_channel='LOCATION_X', target_space='LOCAL', frame_start=0, frame_end=2, trans_min=-0.05, trans_max=0.05, is_corrective=False, - trigger_action_a=None, trigger_action_b=None): + trigger_a=None, trigger_b=None): self.action = action self.enabled = enabled self.symmetrical = symmetrical @@ -162,8 +164,8 @@ class GeneratedActionSlot(ActionSlotBase): self.trans_min = trans_min self.trans_max = trans_max self.is_corrective = is_corrective - self.trigger_action_a = trigger_action_a - self.trigger_action_b = trigger_action_b + self.trigger_a = trigger_a + self.trigger_b = trigger_b class ActionLayer(RigComponent): @@ -186,8 +188,8 @@ class ActionLayer(RigComponent): self.use_trigger = False if slot.is_corrective: - trigger_a = self.owner.action_map[slot.trigger_action_a.name] - trigger_b = self.owner.action_map[slot.trigger_action_b.name] + trigger_a = self.owner.action_map[slot.trigger_a.name] + trigger_b = self.owner.action_map[slot.trigger_b.name] self.trigger_a = trigger_a.get(side) or trigger_a.get(Side.MIDDLE) self.trigger_b = trigger_b.get(side) or trigger_b.get(Side.MIDDLE) @@ -207,7 +209,7 @@ class ActionLayer(RigComponent): return self.slot.is_corrective or self.use_trigger def _get_name(self): - name = self.slot.action.name + name = self.slot.name if self.side == Side.LEFT: name += ".L" @@ -241,7 +243,7 @@ class ActionLayer(RigComponent): def rig_bones(self): if self.slot.is_corrective and self.use_trigger: - raise MetarigError(f"Corrective action used as trigger: {self.slot.action.name}") + raise MetarigError(f"Corrective action used as trigger: {self.slot.name}") if self.use_property: self.rig_input_driver(self.owner.property_bone, quote_property(self.name)) @@ -265,6 +267,7 @@ class ActionLayer(RigComponent): insert_index=0, use_eval_time=True, action=self.slot.action, + action_slot=self.slot.action_slot, frame_start=self.slot.frame_start, frame_end=self.slot.frame_end, mix_mode='BEFORE_SPLIT', @@ -303,7 +306,7 @@ class ActionLayer(RigComponent): if control_name not in self.obj.pose.bones: raise MetarigError( - f"Control bone '{control_name}' for action '{self.slot.action.name}' not found") + f"Control bone '{control_name}' for action '{self.slot.name}' not found") channel = self.slot.transform_channel\ .replace("LOCATION", "LOC").replace("ROTATION", "ROT") @@ -364,39 +367,38 @@ class ActionLayerBuilder(GeneratorPlugin, BoneUtilityMixin, MechanismUtilityMixi # Constraints will be added in reverse order because each one is added to the top # of the stack when created. However, Before Original reverses the effective # order of transformations again, restoring the original sequence. - for act_slot in self.sort_slots(action_slots): + for act_slot in self.sort_action_setups(action_slots): self.spawn_slot_layers(act_slot) @staticmethod - def sort_slots(slots: List[ActionSlotBase]): - indices = {slot.action.name: i for i, slot in enumerate(slots)} + def sort_action_setups(action_setups: list[ActionSlotBase]): + indices = {action_setup.unique_id: i for i, action_setup in enumerate(action_setups)} - def action_key(action: Action): - return indices.get(action.name, -1) if action else -1 + def action_key(action_setup: ActionSlotBase) -> int: + return indices.get(action_setup.unique_id, -1) - def slot_key(slot: ActionSlotBase): - # Ensure corrective actions are added after their triggers. - if slot.is_corrective: - return max(action_key(slot.action), - action_key(slot.trigger_action_a) + 0.5, - action_key(slot.trigger_action_b) + 0.5) + def action_setup_key(action_setup: ActionSlotBase) -> float: + # Ensure corrective actions are added AFTER their triggers. + if action_setup.is_corrective: + return max( + action_key(action_setup), + action_key(action_setup.trigger_a) + 0.5, + action_key(action_setup.trigger_b) + 0.5, + ) else: - return action_key(slot.action) + return action_key(action_setup) - return sorted(slots, key=slot_key) + return sorted(action_setups, key=action_setup_key) def spawn_slot_layers(self, act_slot): - name = act_slot.action.name - - if name in self.action_map: - raise MetarigError(f"Action slot with duplicate action: {name}") + name = act_slot.name if act_slot.is_corrective: - if not act_slot.trigger_action_a or not act_slot.trigger_action_b: + if not act_slot.trigger_a or not act_slot.trigger_b: raise MetarigError(f"Action slot has missing triggers: {name}") - trigger_a = self.action_map.get(act_slot.trigger_action_a.name) - trigger_b = self.action_map.get(act_slot.trigger_action_b.name) + trigger_a = self.action_map.get(act_slot.trigger_a.name) + trigger_b = self.action_map.get(act_slot.trigger_b.name) if not trigger_a or not trigger_b: raise MetarigError(f"Action slot references missing trigger slot(s): {name}") @@ -423,7 +425,30 @@ class ActionLayerBuilder(GeneratorPlugin, BoneUtilityMixin, MechanismUtilityMixi def rig_bones(self): if self.layers: self.child_meshes = [ - verify_mesh_obj(child) - for child in self.generator.obj.children_recursive - if child.type == 'MESH' + verify_mesh_obj(child) for child in self.generator.obj.children_recursive if child.type == 'MESH' ] + + +@bpy.app.handlers.persistent +def versioning_5_0(_): + """This is a load_post handler, registered in the top-most level __init__.py.""" + for obj in bpy.data.objects: + if obj.type != 'ARMATURE' or obj.library: + # We only care about armatures, which are local to this file. + continue + for action_setup in obj.data.rigify_action_slots: + if not action_setup.action: + continue + action_setup.action_slot = next( + (s for s in action_setup.action.slots if s.target_id_type in ('UNSPECIFIED', 'OBJECT')), None + ) + sys_props = action_setup.bl_system_properties_get() + for prop_name in ('trigger_action_a', 'trigger_action_b'): + trigger_action = sys_props.get(prop_name, None) + if not trigger_action: + continue + trigger_action_setup = next( + (setup for setup in obj.data.rigify_action_slots if setup.action == trigger_action) + ) + setattr(action_setup, prop_name.replace("_action", ""), trigger_action_setup) + del sys_props[prop_name]