Previously when an action was baked, the slot name was not retained. This causes problems when switching between actions because the slot will not automatically be assigned. This is now fixed by ensuring that the name of the last assigned slot is used to create the new slot. Pull Request: https://projects.blender.org/blender/blender/pulls/136814
244 lines
11 KiB
Python
244 lines
11 KiB
Python
# SPDX-FileCopyrightText: 2025 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import unittest
|
|
import sys
|
|
import pathlib
|
|
|
|
import bpy
|
|
from bpy_extras import anim_utils
|
|
|
|
|
|
"""
|
|
blender -b --factory-startup --python tests/python/bl_animation_bake.py
|
|
"""
|
|
|
|
OBJECT_BAKE_OPTIONS = anim_utils.BakeOptions(
|
|
only_selected=False,
|
|
do_pose=False,
|
|
do_object=True,
|
|
do_visual_keying=False,
|
|
do_constraint_clear=False,
|
|
do_parents_clear=False,
|
|
do_clean=False,
|
|
do_location=True,
|
|
do_rotation=True,
|
|
do_scale=True,
|
|
do_bbone=False,
|
|
do_custom_props=False,
|
|
)
|
|
|
|
|
|
class ObjectBakeTest(unittest.TestCase):
|
|
"""This tests the animation baking to document the current behavior without any attempt of declaring that behavior correct or good."""
|
|
obj: bpy.types.Object
|
|
|
|
def setUp(self) -> None:
|
|
bpy.ops.wm.read_homefile(use_factory_startup=True)
|
|
self.obj = bpy.data.objects.new("test_object", None)
|
|
bpy.context.scene.collection.objects.link(self.obj)
|
|
self.obj.animation_data_create()
|
|
|
|
def test_bake_object_without_animation(self):
|
|
self.assertEqual(self.obj.animation_data.action, None)
|
|
|
|
anim_utils.bake_action_objects([(self.obj, None)], frames=range(0, 10), bake_options=OBJECT_BAKE_OPTIONS)
|
|
|
|
action = self.obj.animation_data.action
|
|
self.assertIsNotNone(action, "Baking without an existing action should create an action")
|
|
self.assertEqual(len(action.slots), 1, "Baking should have created a slot")
|
|
self.assertEqual(action.slots[0], self.obj.animation_data.action_slot)
|
|
channelbag = anim_utils.action_get_channelbag_for_slot(action, action.slots[0])
|
|
|
|
self.assertIsNotNone(channelbag)
|
|
self.assertEqual(len(channelbag.fcurves), 9, "If no animation is present, FCurves are created for all channels")
|
|
|
|
for fcurve in channelbag.fcurves:
|
|
self.assertEqual(len(fcurve.keyframe_points), 10, f"Unexpected key count on {fcurve.data_path}")
|
|
self.assertAlmostEqual(fcurve.keyframe_points[0].co.x, 0, 6,
|
|
f"Unexpected key y position on {fcurve.data_path}")
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 9, 6, "Baking range is exclusive for the end")
|
|
|
|
def test_bake_object_animation_to_new_action(self):
|
|
action = bpy.data.actions.new("test_action")
|
|
self.obj.animation_data.action = action
|
|
|
|
bpy.context.scene.frame_set(0)
|
|
self.obj.keyframe_insert("location")
|
|
bpy.context.scene.frame_set(15)
|
|
self.obj.location = (1, 1, 1)
|
|
self.obj.keyframe_insert("location")
|
|
|
|
# Passing None here will create a new action.
|
|
anim_utils.bake_action_objects([(self.obj, None)], frames=range(0, 10), bake_options=OBJECT_BAKE_OPTIONS)
|
|
|
|
self.assertNotEqual(action, self.obj.animation_data.action, "Expected baking to result in a new action")
|
|
baked_action = self.obj.animation_data.action
|
|
self.assertEqual(len(baked_action.slots), 1)
|
|
self.assertEqual(baked_action.slots[0].name_display, action.slots[0].name_display)
|
|
channelbag = anim_utils.action_get_channelbag_for_slot(baked_action, self.obj.animation_data.action_slot)
|
|
|
|
self.assertIsNotNone(channelbag)
|
|
self.assertEqual(len(channelbag.fcurves), 9)
|
|
|
|
for fcurve in channelbag.fcurves:
|
|
self.assertEqual(len(fcurve.keyframe_points), 10, f"Unexpected key count on {fcurve.data_path}")
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 9,
|
|
6, f"Baking to a new action should delete all keys outside the given range ({fcurve.data_path})")
|
|
|
|
def test_bake_object_animation_to_existing_action(self):
|
|
action = bpy.data.actions.new("test_action")
|
|
self.obj.animation_data.action = action
|
|
|
|
bpy.context.scene.frame_set(0)
|
|
self.obj.keyframe_insert("location")
|
|
bpy.context.scene.frame_set(15)
|
|
self.obj.location = (1, 1, 1)
|
|
self.obj.keyframe_insert("location")
|
|
|
|
# Passing the action as the second element of the tuple means that it will be written into.
|
|
anim_utils.bake_action_objects([(self.obj, action)], frames=range(0, 10), bake_options=OBJECT_BAKE_OPTIONS)
|
|
|
|
self.assertEqual(self.obj.animation_data.action, action)
|
|
self.assertEqual(len(action.slots), 1)
|
|
channelbag = anim_utils.action_get_channelbag_for_slot(action, self.obj.animation_data.action_slot)
|
|
|
|
self.assertIsNotNone(channelbag)
|
|
self.assertEqual(len(channelbag.fcurves), 9)
|
|
|
|
for fcurve in channelbag.fcurves:
|
|
if fcurve.data_path == "location":
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 15,
|
|
6, f"Baking over an existing action should preserve all keys even those out of range ({fcurve.data_path})")
|
|
self.assertEqual(len(fcurve.keyframe_points), 11, f"Unexpected key count on {fcurve.data_path}")
|
|
else:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 9,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
self.assertEqual(len(fcurve.keyframe_points), 10, f"Unexpected key count on {fcurve.data_path}")
|
|
|
|
def test_bake_object_multi_slot_to_new_action(self):
|
|
obj2 = bpy.data.objects.new("obj2", None)
|
|
bpy.context.scene.collection.objects.link(obj2)
|
|
action = bpy.data.actions.new("test_action")
|
|
self.obj.animation_data.action = action
|
|
obj2.animation_data_create().action = action
|
|
|
|
bpy.context.scene.frame_set(0)
|
|
self.obj.location = (0, 0, 0)
|
|
self.obj.keyframe_insert("location")
|
|
obj2.location = (0, 1, 0)
|
|
obj2.keyframe_insert("location")
|
|
|
|
bpy.context.scene.frame_set(9)
|
|
self.obj.location = (2, 0, 0)
|
|
self.obj.keyframe_insert("location")
|
|
obj2.location = (2, 1, 0)
|
|
obj2.keyframe_insert("location")
|
|
|
|
self.assertIsNotNone(self.obj.animation_data.action_slot)
|
|
self.assertIsNotNone(obj2.animation_data.action_slot)
|
|
self.assertNotEqual(self.obj.animation_data.action_slot, obj2.animation_data.action_slot)
|
|
original_slot = obj2.animation_data.action_slot
|
|
anim_utils.bake_action_objects([(obj2, None)], frames=range(0, 10), bake_options=OBJECT_BAKE_OPTIONS)
|
|
|
|
self.assertNotEqual(action, obj2.animation_data.action, "Expected baking to result in a new action")
|
|
baked_action = obj2.animation_data.action
|
|
self.assertEqual(len(baked_action.slots), 1)
|
|
self.assertEqual(original_slot.name_display, baked_action.slots[0].name_display)
|
|
channelbag = anim_utils.action_get_channelbag_for_slot(baked_action, baked_action.slots[0])
|
|
|
|
for fcurve in channelbag.fcurves:
|
|
if fcurve.data_path != "location":
|
|
continue
|
|
# The keyframes should match the animation of obj2, not self.obj.
|
|
if fcurve.array_index == 0:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[0].co.y, 0,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.y, 2,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
elif fcurve.array_index == 1:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[0].co.y, 1,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.y, 1,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
|
|
def test_bake_object_multi_slot_to_existing_action(self):
|
|
obj2 = bpy.data.objects.new("obj2", None)
|
|
bpy.context.scene.collection.objects.link(obj2)
|
|
action = bpy.data.actions.new("test_action")
|
|
self.obj.animation_data.action = action
|
|
obj2.animation_data_create().action = action
|
|
|
|
bpy.context.scene.frame_set(0)
|
|
self.obj.location = (0, 0, 0)
|
|
self.obj.keyframe_insert("location")
|
|
obj2.location = (0, 1, 0)
|
|
obj2.keyframe_insert("location")
|
|
|
|
bpy.context.scene.frame_set(15)
|
|
self.obj.location = (2, 0, 0)
|
|
self.obj.keyframe_insert("location")
|
|
obj2.location = (2, 1, 0)
|
|
obj2.keyframe_insert("location")
|
|
|
|
self.assertEqual(len(action.slots), 2)
|
|
|
|
self.assertIsNotNone(self.obj.animation_data.action_slot)
|
|
self.assertIsNotNone(obj2.animation_data.action_slot)
|
|
|
|
anim_utils.bake_action_objects([(obj2, action)], frames=range(0, 10), bake_options=OBJECT_BAKE_OPTIONS)
|
|
|
|
self.assertEqual(action, obj2.animation_data.action)
|
|
self.assertEqual(len(action.slots), 2, "Didn't expect baking to create a new slot")
|
|
self.assertNotEqual(obj2.animation_data.action_slot, self.obj.animation_data.action_slot)
|
|
|
|
channelbag = anim_utils.action_get_channelbag_for_slot(action, obj2.animation_data.action_slot)
|
|
|
|
self.assertIsNotNone(channelbag)
|
|
self.assertEqual(len(channelbag.fcurves), 9)
|
|
|
|
for fcurve in channelbag.fcurves:
|
|
# The keyframes should match the animation of obj2, not self.obj.
|
|
if fcurve.data_path == "location":
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 15,
|
|
6, f"Baking over an existing action should preserve all keys even those out of range ({fcurve.data_path})")
|
|
self.assertEqual(len(fcurve.keyframe_points), 11, f"Unexpected key count on {fcurve.data_path}")
|
|
if fcurve.array_index == 0:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.y, 2,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
elif fcurve.array_index == 1:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.y, 1,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
else:
|
|
self.assertAlmostEqual(fcurve.keyframe_points[-1].co.x, 9,
|
|
6, f"Unexpected key y position on {fcurve.data_path}")
|
|
self.assertEqual(len(fcurve.keyframe_points), 10, f"Unexpected key count on {fcurve.data_path}")
|
|
|
|
|
|
def main():
|
|
global args
|
|
import argparse
|
|
|
|
argv = [sys.argv[0]]
|
|
if '--' in sys.argv:
|
|
argv += sys.argv[sys.argv.index('--') + 1:]
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
dest="output_dir",
|
|
type=pathlib.Path,
|
|
default=pathlib.Path("."),
|
|
help="Where to output temp saved blendfiles",
|
|
required=False,
|
|
)
|
|
|
|
args, remaining = parser.parse_known_args(argv)
|
|
|
|
unittest.main(argv=remaining)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|