diff options
Diffstat (limited to 'rigify')
-rw-r--r-- | rigify/__init__.py | 1 | ||||
-rw-r--r-- | rigify/generate.py | 3 | ||||
-rw-r--r-- | rigify/operators/__init__.py | 2 | ||||
-rw-r--r-- | rigify/operators/action_layers.py | 525 | ||||
-rw-r--r-- | rigify/operators/generic_ui_list.py | 187 | ||||
-rw-r--r-- | rigify/utils/action_layers.py | 411 |
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' + ] |