Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender-addons.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--rigify/__init__.py1
-rw-r--r--rigify/generate.py3
-rw-r--r--rigify/operators/__init__.py2
-rw-r--r--rigify/operators/action_layers.py525
-rw-r--r--rigify/operators/generic_ui_list.py187
-rw-r--r--rigify/utils/action_layers.py411
6 files changed, 1129 insertions, 0 deletions
diff --git a/rigify/__init__.py b/rigify/__init__.py
index 1b5c2680..9bb2eab4 100644
--- a/rigify/__init__.py
+++ b/rigify/__init__.py
@@ -44,6 +44,7 @@ initial_load_order = [
'rig_lists',
'metarig_menu',
'rig_ui_template',
+ 'utils.action_layers',
'generate',
'rot_mode',
'operators',
diff --git a/rigify/generate.py b/rigify/generate.py
index 4f30adff..7060c448 100644
--- a/rigify/generate.py
+++ b/rigify/generate.py
@@ -16,6 +16,7 @@ from .utils.misc import gamma_correct, select_object, ArmatureObject, verify_arm
from .utils.collections import (ensure_collection, list_layer_collections,
filter_layer_collections_by_object)
from .utils.rig import get_rigify_type, get_rigify_layers
+from .utils.action_layers import ActionLayerBuilder
from . import base_generate
from . import rig_ui_template
@@ -37,6 +38,7 @@ class Timer:
class Generator(base_generate.BaseGenerator):
usable_collections: list[bpy.types.LayerCollection]
+ action_layers: ActionLayerBuilder
def __init__(self, context, metarig):
super().__init__(context, metarig)
@@ -456,6 +458,7 @@ class Generator(base_generate.BaseGenerator):
obj.data["rig_id"] = self.rig_id
self.script = rig_ui_template.ScriptGenerator(self)
+ self.action_layers = ActionLayerBuilder(self)
###########################################
bpy.ops.object.mode_set(mode='OBJECT')
diff --git a/rigify/operators/__init__.py b/rigify/operators/__init__.py
index 4cedf4a0..cce9de4d 100644
--- a/rigify/operators/__init__.py
+++ b/rigify/operators/__init__.py
@@ -5,6 +5,8 @@ import importlib
# Submodules to load during register
submodules = (
+ 'generic_ui_list',
+ 'action_layers',
'copy_mirror_parameters',
'upgrade_face',
)
diff --git a/rigify/operators/action_layers.py b/rigify/operators/action_layers.py
new file mode 100644
index 00000000..c089722a
--- /dev/null
+++ b/rigify/operators/action_layers.py
@@ -0,0 +1,525 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import bpy
+
+from typing import Tuple, Optional, Sequence
+
+from bpy.types import PropertyGroup, Action, UIList, UILayout, Context, Panel, Operator, Armature
+from bpy.props import (EnumProperty, IntProperty, BoolProperty, StringProperty, FloatProperty,
+ PointerProperty, CollectionProperty)
+
+from .generic_ui_list import draw_ui_list
+
+from ..utils.naming import mirror_name
+from ..utils.action_layers import ActionSlotBase
+
+
+def get_action_slots(arm: Armature) -> Sequence['ActionSlot']:
+ # noinspection PyUnresolvedReferences
+ return arm.rigify_action_slots
+
+
+def get_action_slots_active(arm: Armature) -> tuple[Sequence['ActionSlot'], int]:
+ # noinspection PyUnresolvedReferences
+ return arm.rigify_action_slots, arm.rigify_active_action_slot
+
+
+def poll_trigger_action(_self, action):
+ """Whether an action can be used as a corrective action's trigger or not."""
+ armature_id_store = bpy.context.object.data
+ assert isinstance(armature_id_store, Armature)
+
+ slots, idx = get_action_slots_active(armature_id_store)
+
+ active_slot = slots[idx] if 0 <= idx < len(slots) else None
+
+ # If this action is the same as the active slot's action, don't show it.
+ if active_slot and action == active_slot.action:
+ return False
+
+ # If this action is used by any other action slot, show it.
+ for slot in slots:
+ if slot.action == action and not slot.is_corrective:
+ return True
+
+ return False
+
+
+class ActionSlot(PropertyGroup, ActionSlotBase):
+ action: PointerProperty(
+ name="Action",
+ type=Action,
+ description="Action to apply to the rig via constraints"
+ )
+
+ enabled: BoolProperty(
+ name="Enabled",
+ description="Create constraints for this action on the generated rig",
+ default=True
+ )
+
+ symmetrical: BoolProperty(
+ name="Symmetrical",
+ description="Apply the same setup but mirrored to the opposite side control, shown in "
+ "parentheses. Bones will only be affected by the control with the same side "
+ "(eg., .L bones will only be affected by the .L control). Bones without a "
+ "side in their name (so no .L or .R) will be affected by both controls "
+ "with 0.5 influence each",
+ default=True
+ )
+
+ subtarget: StringProperty(
+ name="Control Bone",
+ description="Select a bone on the generated rig which will drive this action"
+ )
+
+ transform_channel: EnumProperty(name="Transform Channel",
+ items=[("LOCATION_X", "X Location", "X Location"),
+ ("LOCATION_Y", "Y Location", "Y Location"),
+ ("LOCATION_Z", "Z Location", "Z Location"),
+ ("ROTATION_X", "X Rotation", "X Rotation"),
+ ("ROTATION_Y", "Y Rotation", "Y Rotation"),
+ ("ROTATION_Z", "Z Rotation", "Z Rotation"),
+ ("SCALE_X", "X Scale", "X Scale"),
+ ("SCALE_Y", "Y Scale", "Y Scale"),
+ ("SCALE_Z", "Z Scale", "Z Scale")],
+ description="Transform channel",
+ default="LOCATION_X")
+
+ target_space: EnumProperty(
+ name="Transform Space",
+ items=[("WORLD", "World Space", "World Space"),
+ ("POSE", "Pose Space", "Pose Space"),
+ ("LOCAL_WITH_PARENT", "Local With Parent", "Local With Parent"),
+ ("LOCAL", "Local Space", "Local Space")],
+ default="LOCAL"
+ )
+
+ def update_frame_start(self, _context):
+ if self.frame_start > self.frame_end:
+ self.frame_end = self.frame_start
+
+ frame_start: IntProperty(
+ name="Start Frame",
+ description="First frame of the action's timeline",
+ update=update_frame_start
+ )
+
+ def update_frame_end(self, _context):
+ if self.frame_end < self.frame_start:
+ self.frame_start = self.frame_end
+
+ frame_end: IntProperty(
+ name="End Frame",
+ default=2,
+ description="Last frame of the action's timeline",
+ update=update_frame_end
+ )
+
+ trans_min: FloatProperty(
+ name="Min",
+ default=-0.05,
+ description="Value that the transformation value must reach to put the action's timeline"
+ "to the first frame. Rotations are in degrees"
+ )
+
+ trans_max: FloatProperty(
+ name="Max",
+ default=0.05,
+ description="Value that the transformation value must reach to put the action's timeline"
+ "to the last frame. Rotations are in degrees"
+ )
+
+ is_corrective: BoolProperty(
+ name="Corrective",
+ description="Indicate that this is a corrective action. Corrective actions will activate"
+ "based on the activation of two other actions (using End Frame if both inputs"
+ "are at their End Frame, and Start Frame if either is at Start Frame)"
+ )
+
+ trigger_action_a: PointerProperty(
+ name="Trigger A",
+ type=Action,
+ description="Action whose activation will trigger the corrective action",
+ poll=poll_trigger_action
+ )
+
+ trigger_action_b: PointerProperty(
+ name="Trigger B",
+ description="Action whose activation will trigger the corrective action",
+ type=Action,
+ poll=poll_trigger_action
+ )
+
+ 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
+
+
+class RIGIFY_OT_action_create(Operator):
+ """Create new Action"""
+ # This is needed because bpy.ops.action.new() has a poll function that blocks
+ # the operator unless it's drawn in an animation UI panel.
+
+ bl_idname = "object.rigify_action_create"
+ bl_label = "New"
+ bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
+
+ def execute(self, context):
+ armature_id_store = context.object.data
+ assert isinstance(armature_id_store, Armature)
+ action_slots, action_slot_idx = get_action_slots_active(armature_id_store)
+ action_slot = action_slots[action_slot_idx]
+ action = bpy.data.actions.new(name="Action")
+ action_slot.action = action
+ return {'FINISHED'}
+
+
+class RIGIFY_OT_jump_to_action_slot(Operator):
+ """Set Active Action Slot Index"""
+
+ bl_idname = "object.rigify_jump_to_action_slot"
+ bl_label = "Jump to Action Slot"
+ bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
+
+ to_index: IntProperty()
+
+ def execute(self, context):
+ armature_id_store = context.object.data
+ armature_id_store.rigify_active_action_slot = self.to_index
+ return {'FINISHED'}
+
+
+# =============================================
+# UI Panel
+
+class RIGIFY_UL_action_slots(UIList):
+ def draw_item(self, context: Context, layout: UILayout, data: Armature,
+ action_slot: ActionSlot, icon, active_data, active_propname: str,
+ slot_index: int = 0, flt_flag: int = 0):
+ action_slots, action_slot_idx = get_action_slots_active(data)
+ active_action = action_slots[action_slot_idx]
+
+ if self.layout_type in {'DEFAULT', 'COMPACT'}:
+ if action_slot.action:
+ row = layout.row()
+ icon = 'ACTION'
+
+ # 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]:
+ 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]:
+ icon = 'RESTRICT_INSTANCED_OFF'
+
+ row.prop(action_slot.action, 'name', text="", emboss=False, 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:
+ 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)
+
+ # No trigger action set, no slot or invalid slot
+ if not trigger_slot or trigger_slot.is_corrective:
+ row.alert = True
+ text = "No Trigger Action"
+ icon = 'ERROR'
+ break
+
+ row.label(text=text, icon=icon)
+
+ else:
+ text = action_slot.subtarget
+ icon = 'BONE_DATA'
+
+ # noinspection PyUnresolvedReferences
+ target_rig: Object = data.rigify_target_rig
+
+ if not action_slot.subtarget:
+ row.alert = True
+ text = 'No Control Bone'
+ icon = 'ERROR'
+
+ elif target_rig:
+ # Check for bones not actually present in the generated rig
+ bones = target_rig.pose.bones
+
+ if action_slot.subtarget not in bones:
+ row.alert = True
+ text = 'Bad Control Bone'
+ icon = 'ERROR'
+ elif (action_slot.symmetrical
+ and mirror_name(action_slot.subtarget) not in bones):
+ row.alert = True
+ text = 'Bad Control Bone'
+ icon = 'ERROR'
+
+ row.label(text=text, icon=icon)
+
+ # noinspection SpellCheckingInspection
+ icon = 'CHECKBOX_HLT' if action_slot.enabled else 'CHECKBOX_DEHLT'
+ row.enabled = action_slot.enabled
+
+ layout.prop(action_slot, 'enabled', text="", icon=icon, emboss=False)
+
+ # No action
+ else:
+ layout.label(text="", translate=False, icon='ACTION')
+
+ elif self.layout_type in {'GRID'}:
+ layout.alignment = 'CENTER'
+ layout.label(text="", icon_value=icon)
+
+
+class DATA_PT_rigify_actions(Panel):
+ bl_space_type = 'PROPERTIES'
+ bl_region_type = 'WINDOW'
+ bl_context = 'data'
+ bl_label = "Actions"
+ bl_parent_id = "DATA_PT_rigify"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.object.mode in ('POSE', 'OBJECT')
+
+ def draw(self, context: Context):
+ armature_id_store = context.object.data
+ assert isinstance(armature_id_store, Armature)
+ action_slots, active_idx = get_action_slots_active(armature_id_store)
+
+ layout = self.layout
+ layout.use_property_split = True
+ layout.use_property_decorate = False
+
+ draw_ui_list(
+ layout, context,
+ class_name='RIGIFY_UL_action_slots',
+ list_context_path='object.data.rigify_action_slots',
+ active_idx_context_path='object.data.rigify_active_action_slot',
+ )
+
+ if len(action_slots) == 0:
+ return
+
+ active_slot = action_slots[active_idx]
+
+ layout.template_ID(active_slot, 'action', new=RIGIFY_OT_action_create.bl_idname)
+
+ if not active_slot.action:
+ return
+
+ layout = layout.column()
+ layout.prop(active_slot, 'is_corrective')
+
+ if active_slot.is_corrective:
+ self.draw_ui_corrective(context, active_slot)
+ else:
+ # noinspection PyUnresolvedReferences
+ target_rig = armature_id_store.rigify_target_rig
+ self.draw_slot_ui(layout, active_slot, target_rig)
+ self.draw_status(active_slot)
+
+ def draw_ui_corrective(self, context: Context, slot):
+ layout = self.layout
+
+ layout.prop(slot, 'frame_start', text="Frame Start")
+ layout.prop(slot, 'frame_end', text="End")
+ layout.separator()
+
+ for trigger_prop in ['trigger_action_a', 'trigger_action_b']:
+ self.draw_ui_trigger(context, slot, trigger_prop)
+
+ def draw_ui_trigger(self, context: Context, slot, trigger_prop: str):
+ layout = self.layout
+ metarig = context.object
+ assert isinstance(metarig.data, Armature)
+
+ trigger = getattr(slot, trigger_prop)
+ icon = 'ACTION' if trigger else 'ERROR'
+
+ row = layout.row()
+ row.prop(slot, trigger_prop, 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
+ row.label(text="Action not in list", icon='ERROR')
+ return
+
+ show_prop_name = 'show_action_' + trigger_prop[-1]
+ show = getattr(slot, show_prop_name)
+ icon = 'HIDE_OFF' if show else 'HIDE_ON'
+
+ 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
+
+ if show:
+ col = layout.column(align=True)
+ col.enabled = False
+ # noinspection PyUnresolvedReferences
+ target_rig = metarig.data.rigify_target_rig
+ self.draw_slot_ui(col, trigger_slot, target_rig)
+ col.separator()
+
+ @staticmethod
+ def draw_slot_ui(layout, slot, target_rig):
+ if not target_rig:
+ row = layout.row()
+ row.alert = True
+ row.label(text="Cannot verify bone name without a generated rig", icon='ERROR')
+
+ row = layout.row()
+
+ bone_icon = 'BONE_DATA' if slot.subtarget else 'ERROR'
+
+ if target_rig:
+ subtarget_exists = slot.subtarget in target_rig.pose.bones
+ row.prop_search(slot, 'subtarget', target_rig.pose, 'bones', icon=bone_icon)
+ row.alert = not subtarget_exists
+
+ if slot.subtarget and not subtarget_exists:
+ row = layout.split(factor=0.4)
+ row.column()
+ row.alert = True
+ row.label(text=f"Bone not found: {slot.subtarget}", icon='ERROR')
+ else:
+ row.prop(slot, 'subtarget', icon=bone_icon)
+
+ flipped_subtarget = mirror_name(slot.subtarget)
+
+ if flipped_subtarget != slot.subtarget:
+ flipped_subtarget_exists = not target_rig or flipped_subtarget in target_rig.pose.bones
+
+ row = layout.row()
+ row.use_property_split = True
+ row.prop(slot, 'symmetrical', text=f"Symmetrical ({flipped_subtarget})")
+
+ if slot.symmetrical and not flipped_subtarget_exists:
+ row.alert = True
+
+ row = layout.split(factor=0.4)
+ row.column()
+ row.alert = True
+ row.label(text=f"Bone not found: {flipped_subtarget}", icon='ERROR')
+
+ layout.prop(slot, 'frame_start', text="Frame Start")
+ layout.prop(slot, 'frame_end', text="End")
+
+ layout.prop(slot, 'target_space', text="Target Space")
+ layout.prop(slot, 'transform_channel', text="Transform Channel")
+
+ layout.prop(slot, 'trans_min')
+ layout.prop(slot, 'trans_max')
+
+ def draw_status(self, slot):
+ """
+ There are a lot of ways to create incorrect Action setups, so give
+ the user a warning in those cases.
+ """
+ layout = self.layout
+
+ split = layout.split(factor=0.4)
+ heading = split.row()
+ heading.alignment = 'RIGHT'
+ heading.label(text="Status:")
+
+ if slot.trans_min == slot.trans_max:
+ col = split.column(align=True)
+ col.alert = True
+ col.label(text="Min and max value are the same!")
+ col.label(text=f"Will be stuck reading frame {slot.frame_start}!")
+ return
+
+ if slot.frame_start == slot.frame_end:
+ col = split.column(align=True)
+ col.alert = True
+ col.label(text="Start and end frame cannot be the same!")
+
+ default_frame = slot.get_default_frame()
+
+ if slot.is_default_frame_integer():
+ split.label(text=f"Default Frame: {round(default_frame)}")
+ else:
+ split.alert = True
+ split.label(text=f"Default Frame: {round(default_frame, 2)} "
+ "(Should be a whole number!)")
+
+
+# =============================================
+# Registration
+
+classes = (
+ ActionSlot,
+ RIGIFY_OT_action_create,
+ RIGIFY_OT_jump_to_action_slot,
+ RIGIFY_UL_action_slots,
+ DATA_PT_rigify_actions,
+)
+
+
+def register():
+ from bpy.utils import register_class
+ for cls in classes:
+ register_class(cls)
+
+ bpy.types.Armature.rigify_action_slots = CollectionProperty(type=ActionSlot)
+ bpy.types.Armature.rigify_active_action_slot = IntProperty(min=0, default=0)
+
+
+def unregister():
+ from bpy.utils import unregister_class
+ for cls in classes:
+ unregister_class(cls)
+
+ # noinspection PyUnresolvedReferences
+ del bpy.types.Armature.rigify_action_slots
+ # noinspection PyUnresolvedReferences
+ del bpy.types.Armature.rigify_active_action_slot
diff --git a/rigify/operators/generic_ui_list.py b/rigify/operators/generic_ui_list.py
new file mode 100644
index 00000000..46f093b3
--- /dev/null
+++ b/rigify/operators/generic_ui_list.py
@@ -0,0 +1,187 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from bpy.types import Operator, UILayout, Context
+from bpy.props import EnumProperty, StringProperty
+
+
+def get_context_attr(context: Context, data_path):
+ return context.path_resolve(data_path)
+
+
+def set_context_attr(context: Context, data_path, value):
+ items = data_path.split('.')
+ setattr(context.path_resolve('.'.join(items[:-1])), items[-1], value)
+
+
+class GenericUIListOperator(Operator):
+ bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
+
+ list_context_path: StringProperty()
+ active_idx_context_path: StringProperty()
+
+ def get_list(self, context):
+ return get_context_attr(context, self.list_context_path)
+
+ def get_active_index(self, context):
+ return get_context_attr(context, self.active_idx_context_path)
+
+ def set_active_index(self, context, index):
+ set_context_attr(context, self.active_idx_context_path, index)
+
+
+class UILIST_OT_entry_remove(GenericUIListOperator):
+ """Remove the selected entry from the list"""
+
+ bl_idname = "ui.rigify_list_entry_remove"
+ bl_label = "Remove Selected Entry"
+
+ def execute(self, context):
+ my_list = self.get_list(context)
+ active_index = self.get_active_index(context)
+
+ my_list.remove(active_index)
+
+ to_index = min(active_index, len(my_list) - 1)
+
+ self.set_active_index(context, to_index)
+
+ return {'FINISHED'}
+
+
+class UILIST_OT_entry_add(GenericUIListOperator):
+ """Add an entry to the list"""
+
+ bl_idname = "ui.rigify_list_entry_add"
+ bl_label = "Add Entry"
+
+ def execute(self, context):
+ my_list = self.get_list(context)
+ active_index = self.get_active_index(context)
+
+ to_index = min(len(my_list), active_index + 1)
+
+ my_list.add()
+ my_list.move(len(my_list) - 1, to_index)
+ self.set_active_index(context, to_index)
+
+ return {'FINISHED'}
+
+
+class UILIST_OT_entry_move(GenericUIListOperator):
+ """Move an entry in the list up or down"""
+
+ bl_idname = "ui.rigify_list_entry_move"
+ bl_label = "Move Entry"
+
+ direction: EnumProperty(
+ name="Direction",
+ items=[('UP', 'UP', 'UP'),
+ ('DOWN', 'DOWN', 'DOWN')],
+ default='UP'
+ )
+
+ def execute(self, context):
+ my_list = self.get_list(context)
+ active_index = self.get_active_index(context)
+
+ to_index = active_index + (1 if self.direction == 'DOWN' else -1)
+
+ if to_index > len(my_list) - 1:
+ to_index = 0
+ elif to_index < 0:
+ to_index = len(my_list) - 1
+
+ my_list.move(active_index, to_index)
+ self.set_active_index(context, to_index)
+
+ return {'FINISHED'}
+
+
+def draw_ui_list(
+ layout, context, class_name="UI_UL_list", *,
+ list_context_path: str, # Eg. "object.vertex_groups".
+ active_idx_context_path: str, # Eg., "object.vertex_groups.active_index".
+ insertion_operators=True,
+ move_operators=True,
+ menu_class_name="",
+ **kwargs) -> UILayout:
+ """
+ This is intended as a replacement for row.template_list().
+ By changing the requirements of the parameters, we can provide the Add, Remove and Move Up/Down
+ operators without the person implementing the UIList having to worry about that stuff.
+ """
+ row = layout.row()
+
+ list_owner = get_context_attr(context, ".".join(list_context_path.split(".")[:-1]))
+ list_prop_name = list_context_path.split(".")[-1]
+ idx_owner = get_context_attr(context, ".".join(active_idx_context_path.split(".")[:-1]))
+ idx_prop_name = active_idx_context_path.split(".")[-1]
+
+ my_list = get_context_attr(context, list_context_path)
+
+ row.template_list(
+ class_name,
+ list_context_path if class_name == 'UI_UL_list' else "",
+ list_owner, list_prop_name,
+ idx_owner, idx_prop_name,
+ rows=4 if len(my_list) > 0 else 1,
+ **kwargs
+ )
+
+ col = row.column()
+
+ if insertion_operators:
+ add_op = col.operator(UILIST_OT_entry_add.bl_idname, text="", icon='ADD')
+ add_op.list_context_path = list_context_path
+ add_op.active_idx_context_path = active_idx_context_path
+
+ row = col.row()
+ row.enabled = len(my_list) > 0
+ remove_op = row.operator(UILIST_OT_entry_remove.bl_idname, text="", icon='REMOVE')
+ remove_op.list_context_path = list_context_path
+ remove_op.active_idx_context_path = active_idx_context_path
+
+ col.separator()
+
+ if menu_class_name != '':
+ # noinspection SpellCheckingInspection
+ col.menu(menu_class_name, icon='DOWNARROW_HLT', text="")
+ col.separator()
+
+ if move_operators and len(my_list) > 0:
+ col = col.column()
+ col.enabled = len(my_list) > 1
+ move_up_op = col.operator(UILIST_OT_entry_move.bl_idname, text="", icon='TRIA_UP')
+ move_up_op.direction = 'UP'
+ move_up_op.list_context_path = list_context_path
+ move_up_op.active_idx_context_path = active_idx_context_path
+
+ move_down_op = col.operator(UILIST_OT_entry_move.bl_idname, text="", icon='TRIA_DOWN')
+ move_down_op.direction = 'DOWN'
+ move_down_op.list_context_path = list_context_path
+ move_down_op.active_idx_context_path = active_idx_context_path
+
+ # Return the right-side column.
+ return col
+
+
+# =============================================
+# Registration
+
+classes = (
+ UILIST_OT_entry_remove,
+ UILIST_OT_entry_add,
+ UILIST_OT_entry_move,
+)
+
+
+def register():
+ from bpy.utils import register_class
+ for cls in classes:
+ register_class(cls)
+
+
+def unregister():
+ from bpy.utils import unregister_class
+ for cls in classes:
+ unregister_class(cls)
diff --git a/rigify/utils/action_layers.py b/rigify/utils/action_layers.py
new file mode 100644
index 00000000..4602e8be
--- /dev/null
+++ b/rigify/utils/action_layers.py
@@ -0,0 +1,411 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from typing import Optional, List, Dict, Tuple
+from bpy.types import Action, Object, Mesh
+from bl_math import clamp
+
+from .errors import MetarigError
+from .naming import Side, get_name_side, change_name_side, mirror_name
+from .bones import BoneUtilityMixin
+from .mechanism import MechanismUtilityMixin, driver_var_transform, quote_property
+
+from ..base_rig import RigComponent, stage
+from ..base_generate import GeneratorPlugin
+
+
+class ActionSlotBase:
+ """Abstract non-RNA base for the action list slots."""
+
+ action: Optional[Action]
+ enabled: bool
+ symmetrical: bool
+ subtarget: str
+ transform_channel: str
+ target_space: str
+ frame_start: int
+ frame_end: int
+ trans_min: float
+ trans_max: float
+ is_corrective: bool
+ trigger_action_a: Optional[Action]
+ trigger_action_b: Optional[Action]
+
+ ############################################
+ # Action Constraint Setup
+
+ @property
+ def keyed_bone_names(self) -> List[str]:
+ """Return a list of bone names that have keyframes in the Action of this Slot."""
+ keyed_bones = []
+
+ for fc in self.action.fcurves:
+ # Extracting bone name from fcurve data path
+ if fc.data_path.startswith('pose.bones["'):
+ bone_name = fc.data_path[12:].split('"]')[0]
+
+ if bone_name not in keyed_bones:
+ keyed_bones.append(bone_name)
+
+ return keyed_bones
+
+ @property
+ def do_symmetry(self) -> bool:
+ return self.symmetrical and get_name_side(self.subtarget) != Side.MIDDLE
+
+ @property
+ def default_side(self):
+ return get_name_side(self.subtarget)
+
+ def get_min_max(self, side=Side.MIDDLE) -> Tuple[float, float]:
+ if side == -self.default_side:
+ # Flip min/max in some cases - based on code of Paste Pose Flipped
+ if self.transform_channel in ['LOCATION_X', 'ROTATION_Z', 'ROTATION_Y']:
+ return -self.trans_min, -self.trans_max
+ return self.trans_min, self.trans_max
+
+ def get_factor_expression(self, var, side=Side.MIDDLE):
+ assert not self.is_corrective
+
+ trans_min, trans_max = self.get_min_max(side)
+
+ if 'ROTATION' in self.transform_channel:
+ var = f'({var}*180/pi)'
+
+ return f'clamp(({var} - {trans_min:.4}) / {trans_max - trans_min:.4})'
+
+ def get_trigger_expression(self, var_a, var_b):
+ assert self.is_corrective
+
+ return f'clamp({var_a} * {var_b})'
+
+ ##################################
+ # Default Frame
+
+ def get_default_channel_value(self) -> float:
+ # The default transformation value for rotation and location is 0, but for scale it's 1.
+ return 1.0 if 'SCALE' in self.transform_channel else 0.0
+
+ def get_default_factor(self, side=Side.MIDDLE, *, triggers=None) -> float:
+ """ Based on the transform channel, and transform range,
+ calculate the evaluation factor in the default pose.
+ """
+ if self.is_corrective:
+ if not triggers or None in triggers:
+ return 0
+
+ val_a, val_b = [trigger.get_default_factor(side) for trigger in triggers]
+
+ return clamp(val_a * val_b)
+
+ else:
+ trans_min, trans_max = self.get_min_max(side)
+
+ if trans_min == trans_max:
+ # Avoid division by zero
+ return 0
+
+ def_val = self.get_default_channel_value()
+ factor = (def_val - trans_min) / (trans_max - trans_min)
+
+ return clamp(factor)
+
+ def get_default_frame(self, side=Side.MIDDLE, *, triggers=None) -> float:
+ """ Based on the transform channel, frame range and transform range,
+ we can calculate which frame within the action should have the keyframe
+ which has the default pose.
+ This is the frame which will be read when the transformation is at its default
+ (so 1.0 for scale and 0.0 for loc/rot)
+ """
+ factor = self.get_default_factor(side, triggers=triggers)
+
+ return self.frame_start * (1 - factor) + self.frame_end * factor
+
+ def is_default_frame_integer(self) -> bool:
+ default_frame = self.get_default_frame()
+
+ return abs(default_frame - round(default_frame)) < 0.001
+
+
+class GeneratedActionSlot(ActionSlotBase):
+ """Non-RNA version of the action list slot."""
+
+ 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):
+ self.action = action
+ self.enabled = enabled
+ self.symmetrical = symmetrical
+ self.subtarget = subtarget
+ self.transform_channel = transform_channel
+ self.target_space = target_space
+ self.frame_start = frame_start
+ self.frame_end = frame_end
+ 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
+
+
+class ActionLayer(RigComponent):
+ """An action constraint layer instance, applying an action to a symmetry side."""
+
+ rigify_sub_object_run_late = True
+
+ owner: 'ActionLayerBuilder'
+ slot: ActionSlotBase
+ side: Side
+
+ def __init__(self, owner, slot, side):
+ super().__init__(owner)
+
+ self.slot = slot
+ self.side = side
+
+ self.name = self._get_name()
+
+ 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]
+
+ 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)
+
+ self.trigger_a.use_trigger = True
+ self.trigger_b.use_trigger = True
+
+ else:
+ self.bone_name = change_name_side(slot.subtarget, side)
+
+ self.bones = self._filter_bones()
+
+ self.owner.layers.append(self)
+
+ @property
+ def use_property(self):
+ return self.slot.is_corrective or self.use_trigger
+
+ def _get_name(self):
+ name = self.slot.action.name
+
+ if self.side == Side.LEFT:
+ name += ".L"
+ elif self.side == Side.RIGHT:
+ name += ".R"
+
+ return name
+
+ def _filter_bones(self):
+ controls = self._control_bones()
+ bones = [bone for bone in self.slot.keyed_bone_names if bone not in controls]
+
+ if self.side != Side.MIDDLE:
+ bones = [name for name in bones if get_name_side(name) in (self.side, Side.MIDDLE)]
+
+ return bones
+
+ def _control_bones(self):
+ if self.slot.is_corrective:
+ return self.trigger_a._control_bones() | self.trigger_b._control_bones()
+ elif self.slot.do_symmetry:
+ return {self.bone_name, mirror_name(self.bone_name)}
+ else:
+ return {self.bone_name}
+
+ def configure_bones(self):
+ if self.use_property:
+ factor = self.slot.get_default_factor(self.side)
+
+ self.make_property(self.owner.property_bone, self.name, float(factor))
+
+ 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}")
+
+ if self.use_property:
+ self.rig_input_driver(self.owner.property_bone, quote_property(self.name))
+
+ for bone_name in self.bones:
+ self.rig_bone(bone_name)
+
+ def rig_bone(self, bone_name):
+ if bone_name not in self.obj.pose.bones:
+ raise MetarigError(
+ f"Bone '{bone_name}' from action '{self.slot.action.name}' not found")
+
+ if self.side != Side.MIDDLE and get_name_side(bone_name) == Side.MIDDLE:
+ influence = 0.5
+ else:
+ influence = 1.0
+
+ con = self.make_constraint(
+ bone_name, 'ACTION',
+ name=f'Action {self.name}',
+ insert_index=0,
+ use_eval_time=True,
+ action=self.slot.action,
+ frame_start=self.slot.frame_start,
+ frame_end=self.slot.frame_end,
+ mix_mode='BEFORE_SPLIT',
+ influence=influence,
+ )
+
+ self.rig_output_driver(con, 'eval_time')
+
+ def rig_output_driver(self, obj, prop):
+ if self.use_property:
+ self.make_driver(obj, prop, variables=[(self.owner.property_bone, self.name)])
+ else:
+ self.rig_input_driver(obj, prop)
+
+ def rig_input_driver(self, obj, prop):
+ if self.slot.is_corrective:
+ self.rig_corrective_driver(obj, prop)
+ else:
+ self.rig_factor_driver(obj, prop)
+
+ def rig_corrective_driver(self, obj, prop):
+ self.make_driver(
+ obj, prop,
+ expression=self.slot.get_trigger_expression('a', 'b'),
+ variables={
+ 'a': (self.owner.property_bone, self.trigger_a.name),
+ 'b': (self.owner.property_bone, self.trigger_b.name),
+ }
+ )
+
+ def rig_factor_driver(self, obj, prop):
+ if self.side != Side.MIDDLE:
+ control_name = change_name_side(self.slot.subtarget, self.side)
+ else:
+ control_name = self.slot.subtarget
+
+ if control_name not in self.obj.pose.bones:
+ raise MetarigError(
+ f"Control bone '{control_name}' for action '{self.slot.action.name}' not found")
+
+ # noinspection SpellCheckingInspection
+ self.make_driver(
+ obj, prop,
+ expression=self.slot.get_factor_expression('var', side=self.side),
+ variables=[
+ driver_var_transform(
+ self.obj, control_name,
+ type=self.slot.transform_channel.replace("ATION", ""),
+ space=self.slot.target_space,
+ rotation_mode='SWING_TWIST_Y',
+ )
+ ]
+ )
+
+ @stage.rig_bones
+ def rig_child_shape_keys(self):
+ for child in self.owner.child_meshes:
+ # noinspection PyTypeChecker
+ mesh: Mesh = child.data
+
+ if mesh.shape_keys:
+ for key_block in mesh.shape_keys.key_blocks[1:]:
+ if key_block.name == self.name:
+ self.rig_shape_key(key_block)
+
+ def rig_shape_key(self, key_block):
+ self.rig_output_driver(key_block, 'value')
+
+
+class ActionLayerBuilder(GeneratorPlugin, BoneUtilityMixin, MechanismUtilityMixin):
+ """
+ Implements centralized generation of action layer constraints.
+ """
+
+ slot_list: List[ActionSlotBase]
+ layers: List[ActionLayer]
+ action_map: Dict[str, Dict[Side, ActionLayer]]
+ property_bone: Optional[str]
+ child_meshes: List[Object]
+
+ def __init__(self, generator):
+ super().__init__(generator)
+
+ metarig_data = generator.metarig.data
+ # noinspection PyUnresolvedReferences
+ self.slot_list = list(metarig_data.rigify_action_slots)
+ self.layers = []
+
+ def initialize(self):
+ if self.slot_list:
+ self.action_map = {}
+ self.rigify_sub_objects = []
+
+ # Generate layers for active valid slots
+ action_slots = [slot for slot in self.slot_list if slot.enabled and slot.action]
+
+ # 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):
+ 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 action_key(action: Action):
+ return indices.get(action.name, -1) if action else -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)
+ else:
+ return action_key(slot.action)
+
+ return sorted(slots, key=slot_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}")
+
+ if act_slot.is_corrective:
+ if not act_slot.trigger_action_a or not act_slot.trigger_action_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)
+
+ if not trigger_a or not trigger_b:
+ raise MetarigError(f"Action slot references missing trigger slot(s): {name}")
+
+ symmetry = Side.LEFT in trigger_a or Side.LEFT in trigger_b
+
+ else:
+ symmetry = act_slot.do_symmetry
+
+ if symmetry:
+ self.action_map[name] = {
+ Side.LEFT: ActionLayer(self, act_slot, Side.LEFT),
+ Side.RIGHT: ActionLayer(self, act_slot, Side.RIGHT),
+ }
+ else:
+ self.action_map[name] = {
+ Side.MIDDLE: ActionLayer(self, act_slot, Side.MIDDLE)
+ }
+
+ def generate_bones(self):
+ if any(child.use_property for child in self.layers):
+ self.property_bone = self.new_bone("MCH-action-props")
+
+ def rig_bones(self):
+ if self.layers:
+ self.child_meshes = [
+ child
+ for child in self.generator.obj.children_recursive
+ if child.type == 'MESH'
+ ]