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
This commit is contained in:
Alexander Gavrilov 2022-10-25 16:12:18 +03:00
parent d477a245c0
commit dddf346f1a
6 changed files with 1129 additions and 0 deletions

View File

@ -44,6 +44,7 @@ initial_load_order = [
'rig_lists',
'metarig_menu',
'rig_ui_template',
'utils.action_layers',
'generate',
'rot_mode',
'operators',

View File

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

View File

@ -5,6 +5,8 @@ import importlib
# Submodules to load during register
submodules = (
'generic_ui_list',
'action_layers',
'copy_mirror_parameters',
'upgrade_face',
)

View File

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

View File

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

View File

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