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:
authorAlexander Gavrilov <angavrilov@gmail.com>2022-10-25 16:12:18 +0300
committerAlexander Gavrilov <angavrilov@gmail.com>2022-11-07 17:16:57 +0300
commitdddf346f1adf49f51351ead084fbb7d5e1a8f9a0 (patch)
tree2595a655dcd9d169ee22f8323c4588fb2ac68b51
parentd477a245c03c2c5227c97825b20e62d1469e24b2 (diff)
Rigify: port the ability to generate Action constraints from CloudRig.
CloudRig has a feature that allows the user to automatically generate Action constraints that move bones of the rig based on the position of other bones. This is done by adding and configuring the actions in a UI panel of the metarig. The feature also supports corrective actions that activate based on the state of two other actions. This ports the feature to base Rigify with the necessary changes in code organization and style, and replacing CloudRig-specific code. There are also some functional changes: - The order of action constraints is reversed. - The way symmetry of LOCATION_X is handed is changed to match how Paste Pose Flipped works. - The action slot UI is shown even without a generated rig. - More alerts in the UI, e.g. for duplicate rows. Differential Revision: https://developer.blender.org/D16336
-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'
+ ]