Rigify: Full Action Slot Support in Blender 5.0

This commit tries to make the bare minimum changes to add a meaningful
level of support for Action Slots in Rigify:

- You can now select an Action Slot in each Action Set-up, and that
  will be assigned to the generated Action Constraints.
- For this to be meaningful, we have to support selecting the same
  Action in multiple Action Set-ups, which would previously throw an
  error.
- For that to be possible however, it was necessary to make the
  trigger selectors of Corrective Action Set-ups select one of the
  other set-ups directly, rather than selecting an Action (datablock),
  and then making an association to one of the action set-ups based on
  that action pointer. (The necessity to allow users to point at
  another action set-up was the reason behind not allowing user to use
  the same action datablock multiple times.)

Pull Request: https://projects.blender.org/blender/blender/pulls/146182
This commit is contained in:
Demeter Dzadik
2025-10-07 18:37:44 +02:00
committed by Sybren A. Stüvel
parent 76c03744a8
commit f5428c51e0
4 changed files with 271 additions and 113 deletions

View File

@@ -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.)

View File

@@ -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]

View File

@@ -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

View File

@@ -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]