import bpy
import math
from mathutils import Matrix
from bpy_extras import anim_utils
# ============================================================
# Settings
# ============================================================
ROOT_BONE_NAME = "Root"
# If True:
# Bake a keyframe on every frame between the first and last
# root bone keyframe.
# This preserves interpolation more accurately, but creates
# many additional keyframes.
#
# If False:
# Process only frames that already contain root bone keys.
# This behaves similarly to the original version of the script.
BAKE_EVERY_FRAME = False
# Delete the root bone after the transfer is complete.
DELETE_ROOT_BONE = True
# ============================================================
# Blender 5.x / Legacy Action Compatibility
# ============================================================
def get_action_fcurves(animated_id):
"""
Return all F-Curves from the Action currently assigned
to the specified animated datablock.
Blender 4.x and earlier:
action.fcurves
Blender 5.x:
Action Slot
-> Channelbag
-> F-Curves
"""
animation_data = animated_id.animation_data
if animation_data is None:
return []
action = animation_data.action
if action is None:
return []
# Legacy Blender API.
if hasattr(action, "fcurves"):
return list(action.fcurves)
# Blender 5.x layered Action API.
action_slot = getattr(animation_data, "action_slot", None)
# Normally animation_data.action_slot is available.
# As a fallback, use the only slot if the Action has exactly one.
if action_slot is None:
slots = getattr(action, "slots", None)
if slots is not None and len(slots) == 1:
action_slot = slots[0]
if action_slot is None:
print(
f"Warning: No Action slot was found for "
f"'{animated_id.name}'."
)
return []
channelbag = anim_utils.action_get_channelbag_for_slot(
action,
action_slot
)
if channelbag is None:
return []
return list(channelbag.fcurves)
def collect_keyframes(fcurves, data_path_prefix=None):
"""
Collect all unique keyframe times from a list of F-Curves.
If data_path_prefix is specified, only F-Curves whose
data paths start with that prefix are included.
"""
frames = set()
for fcurve in fcurves:
if (
data_path_prefix is not None
and not fcurve.data_path.startswith(data_path_prefix)
):
continue
for keyframe in fcurve.keyframe_points:
frames.add(float(keyframe.co.x))
return sorted(frames)
def set_scene_frame(scene, frame):
"""
Set the current timeline position with subframe support,
then update the dependency graph.
"""
whole_frame = math.floor(frame)
subframe = frame - whole_frame
scene.frame_set(whole_frame, subframe=subframe)
bpy.context.view_layer.update()
# ============================================================
# Main
# ============================================================
scene = bpy.context.scene
# Store the original timeline position so it can be restored later.
original_frame = scene.frame_current + scene.frame_subframe
# Use the currently active object as the source armature.
armature = bpy.context.active_object
if armature is None or armature.type != 'ARMATURE':
raise ValueError("Please select an armature object.")
bpy.context.view_layer.objects.active = armature
armature.select_set(True)
# Switch to Object Mode before reading transforms.
if armature.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# ============================================================
# Validate Root Bone
# ============================================================
root_pose_bone = armature.pose.bones.get(ROOT_BONE_NAME)
if root_pose_bone is None:
raise ValueError(
f"Root bone '{ROOT_BONE_NAME}' was not found."
)
root_data_bone = armature.data.bones.get(ROOT_BONE_NAME)
if root_data_bone is None:
raise ValueError(
f"Root data bone '{ROOT_BONE_NAME}' was not found."
)
# ============================================================
# Store Root Bone Rest Transform
# ============================================================
armature_matrix_world = armature.matrix_world.copy()
root_bone_matrix_rest = root_data_bone.matrix_local.copy()
root_bone_matrix_world_rest = (
armature_matrix_world
@ root_bone_matrix_rest
)
(
root_bone_default_loc,
root_bone_default_rot,
root_bone_default_scale
) = root_bone_matrix_world_rest.decompose()
inverse_root_bone_default_rot = (
root_bone_default_rot.inverted()
)
# ============================================================
# Collect IK Targets
# ============================================================
ik_targets = []
for pose_bone in armature.pose.bones:
for constraint in pose_bone.constraints:
if constraint.type != 'IK':
continue
target = constraint.target
if target is None:
continue
# Only process external IK target objects parented
# to the selected armature.
if target.parent != armature:
continue
# Prevent duplicate targets from being added.
if target not in ik_targets:
ik_targets.append(target)
# ============================================================
# Store Original IK Target World Positions
# ============================================================
ik_target_keyframes = {}
for target in ik_targets:
target_fcurves = get_action_fcurves(target)
target_frames = collect_keyframes(target_fcurves)
ik_target_keyframes[target.name] = {}
for frame in target_frames:
set_scene_frame(scene, frame)
ik_target_keyframes[target.name][frame] = (
target.matrix_world.translation.copy()
)
# ============================================================
# Get Armature Action
# ============================================================
animation_data = armature.animation_data
if animation_data is None:
raise ValueError("Animation data was not found.")
action = animation_data.action
if action is None:
raise ValueError("No Action is assigned to the armature.")
armature_fcurves = get_action_fcurves(armature)
if not armature_fcurves:
raise ValueError(
"No F-Curves were found in the armature Action."
)
# ============================================================
# Collect Root Bone Keyframes
# ============================================================
root_data_path_prefix = (
f'pose.bones["{ROOT_BONE_NAME}"]'
)
root_keyframes = collect_keyframes(
armature_fcurves,
root_data_path_prefix
)
if not root_keyframes:
raise ValueError(
f"No animation keys were found for "
f"root bone '{ROOT_BONE_NAME}'."
)
# Optionally bake every integer frame between
# the first and last root bone keyframe.
if BAKE_EVERY_FRAME:
first_frame = math.floor(min(root_keyframes))
last_frame = math.ceil(max(root_keyframes))
sample_frames = [
float(frame)
for frame in range(first_frame, last_frame + 1)
]
else:
sample_frames = root_keyframes
# ============================================================
# Sample Root Bone Transforms
#
# At this stage, transforms are only recorded.
# No keys are written to the armature object yet.
#
# This prevents newly inserted armature object keyframes
# from affecting transform evaluation on later frames.
# ============================================================
transform_data = {}
for frame in sample_frames:
set_scene_frame(scene, frame)
# PoseBone.matrix is expressed in armature object space.
root_bone_matrix = root_pose_bone.matrix.copy()
# Convert the root bone transform to world space.
global_matrix = (
armature.matrix_world
@ root_bone_matrix
)
global_loc, global_rot, global_scale = (
global_matrix.decompose()
)
transform_data[frame] = {
"location": global_loc.copy(),
"rotation": global_rot.copy(),
"scale": global_scale.copy(),
}
# ============================================================
# Bake the Root Bone Transform to the Armature Object
# ============================================================
bpy.ops.object.mode_set(mode='OBJECT')
# Quaternion rotation avoids Euler angle discontinuities
# during the transfer.
armature.rotation_mode = 'QUATERNION'
translation_matrix_to_default = Matrix.Translation(
-root_bone_default_loc
)
for frame in sample_frames:
data = transform_data[frame]
loc = data["location"]
rot = data["rotation"]
scale = data["scale"]
# Remove the root bone's rest rotation.
corrected_rot = (
rot
@ inverse_root_bone_default_rot
)
translation_matrix_current = Matrix.Translation(loc)
rotation_matrix = corrected_rot.to_matrix().to_4x4()
# Apply the same transform order as the original script:
# 1. Translate relative to the root bone's rest location.
# 2. Apply the corrected root bone rotation.
# 3. Move to the current root bone location.
final_matrix = (
translation_matrix_current
@ rotation_matrix
@ translation_matrix_to_default
)
final_loc, final_rot, _ = final_matrix.decompose()
armature.location = final_loc
armature.rotation_quaternion = final_rot
armature.scale = scale
armature.keyframe_insert(
data_path="location",
frame=frame
)
armature.keyframe_insert(
data_path="rotation_quaternion",
frame=frame
)
armature.keyframe_insert(
data_path="scale",
frame=frame
)
# ============================================================
# Preserve IK Target World Positions
# ============================================================
for target in ik_targets:
saved_frames = ik_target_keyframes.get(target.name, {})
for frame, initial_world_position in saved_frames.items():
set_scene_frame(scene, frame)
# Convert the stored world-space position back into
# the target's armature-relative local space using
# the armature transform at the current frame.
local_position = (
armature.matrix_world.inverted()
@ initial_world_position
)
target.location = local_position
target.keyframe_insert(
data_path="location",
frame=frame
)
# ============================================================
# Delete Root Bone
# ============================================================
if DELETE_ROOT_BONE:
bpy.context.view_layer.objects.active = armature
armature.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
edit_root_bone = armature.data.edit_bones.get(
ROOT_BONE_NAME
)
if edit_root_bone is not None:
armature.data.edit_bones.remove(edit_root_bone)
bpy.ops.object.mode_set(mode='OBJECT')
# ============================================================
# Restore Original Timeline Position
# ============================================================
set_scene_frame(scene, original_frame)
print(
f"Finished. Root bone animation has been transferred "
f"to the armature object '{armature.name}'."
)