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
This commit is contained in:
Sybren A. Stüvel
2025-09-26 15:26:21 +02:00
parent 6f844409b6
commit 7b18a2c324
6 changed files with 157 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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