From 7b18a2c324726c90a0a640cdef6d3fab1850450a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Fri, 26 Sep 2025 15:26:21 +0200 Subject: [PATCH] Refactor: convert Rigify from legacy Action API to the current API Minimal changes to make Rigify use the current Action API (introduced in Blender 4.4) instead of the legacy API (removed in 5.0). Most of the refactoring consists of: - Find the right `Channelbag` - Replace operations on `Action` with operations on that `Channelbag`. I didn't manage to test all code, because some code paths are very hard to follow, and others seem to only be available for legacy rigs. This is part of #146586 Pull Request: https://projects.blender.org/blender/blender/pulls/147060 --- .../rigify/rigs/limbs/spline_tentacle.py | 14 ++- scripts/addons_core/rigify/rot_mode.py | 106 +++++++++++------- scripts/addons_core/rigify/ui.py | 20 ++-- .../addons_core/rigify/utils/action_layers.py | 10 +- scripts/addons_core/rigify/utils/animation.py | 62 ++++++---- scripts/modules/bpy_extras/anim_utils.py | 17 +++ 6 files changed, 157 insertions(+), 72 deletions(-) diff --git a/scripts/addons_core/rigify/rigs/limbs/spline_tentacle.py b/scripts/addons_core/rigify/rigs/limbs/spline_tentacle.py index 0099c02fb2a..329c2221b75 100644 --- a/scripts/addons_core/rigify/rigs/limbs/spline_tentacle.py +++ b/scripts/addons_core/rigify/rigs/limbs/spline_tentacle.py @@ -1161,20 +1161,28 @@ class RigifySplineTentacleToggleControlBase: obj.pose.bones[self.prop_bone][self.prop_name] = value def keyframe_increment(self, context, obj, delta): - action = find_action(obj) + assert isinstance(obj, bpy.types.Object), "Expected {} to be an object".format(obj) + + action = obj.animation_data.action + action_slot = obj.animation_data.action_slot + + assert action, "Expected {} to have an Action assigned".format(obj.name) + assert action_slot, "Expected {} to have an Action slot assigned".format(obj.name) + bone = obj.pose.bones[self.prop_bone] prop_quoted = rna_idprop_quote_path(self.prop_name) # Find the F-Curve data_path = bone.path_from_id(prop_quoted) - fcurve = action.fcurves.find(data_path) or action.fcurves.new(data_path, action_group=self.prop_bone) + channelbag = anim_utils.action_ensure_channelbag_for_slot(action, action_slot) + fcurve = channelbag.fcurves.ensure(data_path, group_name=self.prop_bone) # Ensure the current value is keyed at the start of the animation keyflags = get_keying_flags(context) frame = context.scene.frame_current if len(fcurve.keyframe_points) == 0: - min_x = min(fcu.keyframe_points[0].co[0] for fcu in action.fcurves if len(fcu.keyframe_points) > 0) + min_x = min(fcu.keyframe_points[0].co[0] for fcu in channelbag.fcurves if len(fcu.keyframe_points) > 0) min_frame = nla_tweak_to_scene(obj.animation_data, min_x) if min_frame < frame: bone.keyframe_insert(prop_quoted, frame=min_frame, options=keyflags) diff --git a/scripts/addons_core/rigify/rot_mode.py b/scripts/addons_core/rigify/rot_mode.py index b155a8020e7..9b2945fdd56 100644 --- a/scripts/addons_core/rigify/rot_mode.py +++ b/scripts/addons_core/rigify/rot_mode.py @@ -14,6 +14,7 @@ This script/addon: - Converts multiple Actions TO-DO: + - Properly support slotted Actions. - To convert object's rotation mode (already done in Mutant Bob script, but not done in this one.) - To understand "EnumProperty" and write it well. @@ -47,87 +48,90 @@ from bpy.props import ( EnumProperty, StringProperty, ) +from bpy.types import ( + ActionChannelbag, +) + +from bpy_extras import anim_utils -def get_or_create_fcurve(action, data_path, array_index=-1, group=None): - for fc in action.fcurves: - if fc.data_path == data_path and (array_index < 0 or fc.array_index == array_index): - return fc - - fc = action.fcurves.new(data_path, index=array_index) - fc.group = group - return fc - - -def add_keyframe_quat(action, quat, frame, bone_prefix, group): +def add_keyframe_quat( + channelbag: ActionChannelbag, + quat: list[float], + frame: float, + bone_prefix: str, + group_name: str) -> None: for i in range(len(quat)): - fc = get_or_create_fcurve(action, bone_prefix + "rotation_quaternion", i, group) + fc = channelbag.fcurves.ensure(bone_prefix + "rotation_quaternion", index=i, group_name=group_name) pos = len(fc.keyframe_points) fc.keyframe_points.add(1) fc.keyframe_points[pos].co = [frame, quat[i]] fc.update() -def add_keyframe_euler(action, euler, frame, bone_prefix, group): +def add_keyframe_euler( + channelbag: ActionChannelbag, + euler: list[float], + frame: float, + bone_prefix: str, + group_name: str) -> None: for i in range(len(euler)): - fc = get_or_create_fcurve(action, bone_prefix + "rotation_euler", i, group) + fc = channelbag.fcurves.ensure(bone_prefix + "rotation_euler", index=i, group_name=group_name) pos = len(fc.keyframe_points) fc.keyframe_points.add(1) fc.keyframe_points[pos].co = [frame, euler[i]] fc.update() -def frames_matching(action, data_path): +def frames_matching(channelbag, data_path): frames = set() - for fc in action.fcurves: + for fc in channelbag.fcurves: if fc.data_path == data_path: fri = [kp.co[0] for kp in fc.keyframe_points] frames.update(fri) return frames -def group_qe(_obj, action, bone, bone_prefix, order): - """Converts only one group/bone in one action - Quaternion to euler.""" +def group_qe(_obj, channelbag, bone, bone_prefix, order): + """Converts only one group/bone in one channelbag - Quaternion to euler.""" # pose_bone = bone data_path = bone_prefix + "rotation_quaternion" - frames = frames_matching(action, data_path) - group = action.groups[bone.name] + frames = frames_matching(channelbag, data_path) for fr in frames: quat = bone.rotation_quaternion.copy() - for fc in action.fcurves: + for fc in channelbag.fcurves: if fc.data_path == data_path: quat[fc.array_index] = fc.evaluate(fr) euler = quat.to_euler(order) - add_keyframe_euler(action, euler, fr, bone_prefix, group) + add_keyframe_euler(channelbag, euler, fr, bone_prefix, bone.name) bone.rotation_mode = order -def group_eq(_obj, action, bone, bone_prefix, order): - """Converts only one group/bone in one action - Euler to Quaternion.""" +def group_eq(obj, channelbag, bone, bone_prefix, order): + """Converts only one group/bone in one channelbag - Euler to Quaternion.""" # pose_bone = bone data_path = bone_prefix + "rotation_euler" - frames = frames_matching(action, data_path) - group = action.groups[bone.name] + frames = frames_matching(channelbag, data_path) for fr in frames: euler = bone.rotation_euler.copy() - for fc in action.fcurves: + for fc in channelbag.fcurves: if fc.data_path == data_path: euler[fc.array_index] = fc.evaluate(fr) quat = euler.to_quaternion() - add_keyframe_quat(action, quat, fr, bone_prefix, group) + add_keyframe_quat(channelbag, quat, fr, bone_prefix, bone.name) bone.rotation_mode = order -def convert_curves_of_bone_in_action(obj, action, bone, order): - """Convert given bone's curves in given action to given rotation order.""" +def convert_curves_of_bone(obj, channelbag, bone, order): + """Convert given bone's curves in given channelbag to given rotation order.""" to_euler = False bone_prefix = '' - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: if fcurve.group.name == bone.name: # If To-Euler conversion @@ -149,25 +153,25 @@ def convert_curves_of_bone_in_action(obj, action, bone, order): # If To-Euler conversion if to_euler and order != 'QUATERNION': # Converts the group/bone from Quaternion to Euler - group_qe(obj, action, bone, bone_prefix, order) + group_qe(obj, channelbag, bone, bone_prefix, order) # Removes quaternion fcurves - for key in action.fcurves: + for key in channelbag.fcurves: if key.data_path == 'pose.bones["' + bone.name + '"].rotation_quaternion': fcurves_to_remove.append(key) # If To-Quaternion conversion elif to_euler: # Converts the group/bone from Euler to Quaternion - group_eq(obj, action, bone, bone_prefix, order) + group_eq(obj, channelbag, bone, bone_prefix, order) # Removes euler fcurves - for key in action.fcurves: + for key in channelbag.fcurves: if key.data_path == 'pose.bones["' + bone.name + '"].rotation_euler': fcurves_to_remove.append(key) for fcurve in fcurves_to_remove: - action.fcurves.remove(fcurve) + channelbag.fcurves.remove(fcurve) # Changes rotation mode to new one bone.rotation_mode = order @@ -239,16 +243,40 @@ class POSE_OT_convert_rotation(bpy.types.Operator): def execute(self, context): obj = context.active_object - actions = [bpy.data.actions.get(self.selected_action)] - pose_bones = context.selected_pose_bones + assigned_action = obj.animation_data and obj.animation_data.action + assigned_slot = obj.animation_data and obj.animation_data.action_slot + if self.affected_bones == 'ALL': pose_bones = obj.pose.bones + else: + pose_bones = context.selected_pose_bones + if self.affected_actions == 'ALL': actions = bpy.data.actions + else: + actions = [bpy.data.actions.get(self.selected_action)] for action in actions: + if action == assigned_action: + # On the assigned Action, use the assigned slot. + action_slot = assigned_slot + else: + # Otherwise find a suitable slot to process. + # + # NOTE: this may not pick the right slot if they are not + # consistently named. Also, if there are multiple suitable + # slots, only the first one is converted. + if assigned_slot.identifier in action.slots: + action_slot = action.slots[assigned_slot.identifier] + else: + action_slot = anim_utils.action_get_first_suitable_slot(action, 'OBJECT') + + channelbag = anim_utils.action_get_channelbag_for_slot(action, action_slot) + if not channelbag: + continue + for pb in pose_bones: - convert_curves_of_bone_in_action(obj, action, pb, self.target_rotation_mode) + convert_curves_of_bone(obj, channelbag, pb, self.target_rotation_mode) return {'FINISHED'} diff --git a/scripts/addons_core/rigify/ui.py b/scripts/addons_core/rigify/ui.py index 040d586765b..23341441814 100644 --- a/scripts/addons_core/rigify/ui.py +++ b/scripts/addons_core/rigify/ui.py @@ -16,6 +16,7 @@ from bpy.app.translations import ( pgettext_rpt as rpt_, contexts as i18n_contexts, ) +from bpy_extras import anim_utils from collections import defaultdict from typing import TYPE_CHECKING, Callable, Any @@ -1469,7 +1470,7 @@ def ik_to_fk(rig: ArmatureObject, window='ALL'): break -def clear_animation(act, anim_type, names): +def clear_animation(channelbag, anim_type, names): bones = [] for group in names: if names[group]['limb_type'] == 'arm': @@ -1485,7 +1486,7 @@ def clear_animation(act, anim_type, names): bones.extend([names[group]['controls'][1], names[group]['controls'][2], names[group]['controls'][3], names[group]['controls'][4]]) f_curves = [] - for fcu in act.fcurves: + for fcu in channelbag.fcurves: words = fcu.data_path.split('"') if words[0] == "pose.bones[" and words[1] in bones: f_curves.append(fcu) @@ -1494,7 +1495,7 @@ def clear_animation(act, anim_type, names): return for fcu in f_curves: - act.fcurves.remove(fcu) + channelbag.fcurves.remove(fcu) # Put cleared bones back to rest pose bpy.ops.pose.loc_clear() @@ -1673,7 +1674,7 @@ class OBJECT_OT_ClearAnimation(bpy.types.Operator): bl_idname = "rigify.clear_animation" bl_label = "Clear Animation" bl_description = "Clear animation for FK or IK bones" - bl_options = {'INTERNAL'} + bl_options = {'INTERNAL', 'UNDO'} anim_type: StringProperty() @@ -1681,13 +1682,14 @@ class OBJECT_OT_ClearAnimation(bpy.types.Operator): rig = verify_armature_obj(context.object) if not rig.animation_data: - return {'FINISHED'} + return {'CANCELLED'} - act = rig.animation_data.action - if not act: - return {'FINISHED'} + channelbag = anim_utils.action_get_channelbag_for_slot( + rig.animation_data.action, rig.animation_data.action_slot) + if not channelbag: + return {'CANCELLED'} - clear_animation(act, self.anim_type, names=get_limb_generated_names(rig)) + clear_animation(channelbag, self.anim_type, names=get_limb_generated_names(rig)) return {'FINISHED'} diff --git a/scripts/addons_core/rigify/utils/action_layers.py b/scripts/addons_core/rigify/utils/action_layers.py index 5e7caccd71c..cb6c42e7032 100644 --- a/scripts/addons_core/rigify/utils/action_layers.py +++ b/scripts/addons_core/rigify/utils/action_layers.py @@ -5,6 +5,7 @@ from typing import Optional, List, Dict, Tuple, TYPE_CHECKING from bpy.types import Action, Mesh, Armature from bl_math import clamp +from bpy_extras import anim_utils from .errors import MetarigError from .misc import MeshObject, IdPropSequence, verify_mesh_obj @@ -48,7 +49,14 @@ class ActionSlotBase: """Return a list of bone names that have keyframes in the Action of this Slot.""" keyed_bones = [] - for fc in self.action.fcurves: + # This will get updated to actually support slots properly in #146182. For + # now, just pick the first suitable action slot. + action_slot = anim_utils.action_get_first_suitable_slot(self.action, 'OBJECT') + channelbag = anim_utils.action_get_channelbag_for_slot(self.action, action_slot) + if not channelbag: + return [] + + for fc in channelbag.fcurves: # Extracting bone name from fcurve data path if fc.data_path.startswith('pose.bones["'): bone_name = fc.data_path[12:].split('"]')[0] diff --git a/scripts/addons_core/rigify/utils/animation.py b/scripts/addons_core/rigify/utils/animation.py index 21659582518..de53da9e662 100644 --- a/scripts/addons_core/rigify/utils/animation.py +++ b/scripts/addons_core/rigify/utils/animation.py @@ -8,6 +8,7 @@ from mathutils import Matrix, Vector # noqa from typing import TYPE_CHECKING, Callable, Any, Collection, Iterator, Optional, Sequence from bpy.types import Action, bpy_struct, FCurve +from bpy_extras import anim_utils import json @@ -22,15 +23,24 @@ rig_id = None # Keyframing functions ############################################## -def get_keyed_frames_in_range(context, rig): - action = find_action(rig) - if action: - frame_range = RIGIFY_OT_get_frame_range.get_range(context) +def _get_channelbag_for_rig(rig: bpy.types.Object) -> bpy.types.ActionChannelbag | None: + assert isinstance(rig, bpy.types.Object) + if not rig.animation_data: + return None - return sorted(get_curve_frame_set(action.fcurves, frame_range)) - else: + action = rig.animation_data.action + action_slot = rig.animation_data.action_slot + return anim_utils.action_get_channelbag_for_slot(action, action_slot) + + +def get_keyed_frames_in_range(context: bpy.types.Context, rig: bpy.types.Object) -> list[float]: + channelbag = _get_channelbag_for_rig(rig) + if not channelbag: return [] + frame_range = RIGIFY_OT_get_frame_range.get_range(context) + return sorted(get_curve_frame_set(channelbag.fcurves, frame_range)) + def bones_in_frame(f, rig, *args): """ @@ -41,12 +51,11 @@ def bones_in_frame(f, rig, *args): :return: """ - if rig.animation_data and rig.animation_data.action: - fcurves = rig.animation_data.action.fcurves - else: + channelbag = _get_channelbag_for_rig(rig) + if not channelbag: return False - for fc in fcurves: + for fc in channelbag.fcurves: animated_frames = [kp.co[0] for kp in fc.keyframe_points] for bone in args: if bone in fc.data_path.split('"') and f in animated_frames: @@ -56,14 +65,14 @@ def bones_in_frame(f, rig, *args): def overwrite_prop_animation(rig, bone, prop_name, value, frames): - act = rig.animation_data.action - if not act: + channelbag = _get_channelbag_for_rig(rig) + if not channelbag: return bone_name = bone.name curve = None - for fcu in act.fcurves: + for fcu in channelbag.fcurves: words = fcu.data_path.split('"') if words[0] == "pose.bones[" and words[1] == bone_name and words[-2] == prop_name: curve = fcu @@ -310,6 +319,8 @@ SCRIPT_UTILITIES_CURVES = [''' ## Animation curve tools ## ########################### +from bpy_extras import anim_utils + def flatten_curve_set(curves): "Iterate over all FCurves inside a set of nested lists and dictionaries." if curves is None: @@ -375,9 +386,12 @@ def find_action(action): def clean_action_empty_curves(action): "Delete completely empty curves from the given action." action = find_action(action) - for curve in list(action.fcurves): - if curve.is_empty: - action.fcurves.remove(curve) + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + for curve in channelbag.fcurves[:]: + if curve.is_empty: + channelbag.fcurves.remove(curve) action.update_tag() TRANSFORM_PROPS_LOCATION = frozenset(['location']) @@ -428,11 +442,19 @@ class FCurveTable(object): class ActionCurveTable(FCurveTable): "Table for efficient lookup of Action FCurves by properties." - def __init__(self, action): + def __init__(self, rig: bpy.types.Object) -> None: super().__init__() - self.action = find_action(action) - if self.action: - self.index_curves(self.action.fcurves) + assert isinstance(rig, bpy.types.Object) + + if not rig.animation_data: + return + action = rig.animation_data.action + action_slot = rig.animation_data.action_slot + + channelbag = anim_utils.action_get_channelbag_for_slot(action, action_slot) + if not channelbag: + return + self.index_curves(channelbag.fcurves) class DriverCurveTable(FCurveTable): "Table for efficient lookup of Driver FCurves by properties." diff --git a/scripts/modules/bpy_extras/anim_utils.py b/scripts/modules/bpy_extras/anim_utils.py index 17e51b8bd4d..9807074630f 100644 --- a/scripts/modules/bpy_extras/anim_utils.py +++ b/scripts/modules/bpy_extras/anim_utils.py @@ -96,6 +96,23 @@ def action_get_channelbag_for_slot(action: Action | None, slot: ActionSlot | Non return None +def action_get_first_suitable_slot(action: Action | None, target_id_type: str) -> ActionSlot | None: + """Return the first Slot of the given Action that's suitable for the given ID type. + + Typically you should not need this function; when an Action is assigned to a + data-block, just use the slot that was assigned along with it. + """ + + if not action: + return None + + slot_types = ('UNSPECIFIED', target_id_type) + for slot in action.slots: + if slot.target_id_type in slot_types: + return slot + return None + + def action_ensure_channelbag_for_slot(action: Action, slot: ActionSlot) -> ActionChannelbag: """Ensure a layer and a keyframe strip exists, then ensure that strip has a channelbag for the slot."""