From 3171a22dfdb28fb9c8c6cde6528835e03bfe8cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Mon, 29 Sep 2025 12:42:32 +0200 Subject: [PATCH] Refactor: adjust unit tests to no longer use the legacy Action API Adjust various unit tests so that they no longer use the legacy Action API (which was deprecated in Blender 4.4 and will be removed in 5.0). No functional changes. This is part of #146586 Pull Request: https://projects.blender.org/blender/blender/pulls/147060 --- tests/python/bl_animation_action.py | 197 ------------------------ tests/python/bl_animation_fcurves.py | 71 +++++---- tests/python/bl_animation_keyframing.py | 164 ++++++++++++-------- tests/python/bl_animation_nla_strip.py | 7 +- tests/python/bl_pose_assets.py | 21 ++- tests/python/bl_usd_import_test.py | 9 +- 6 files changed, 167 insertions(+), 302 deletions(-) diff --git a/tests/python/bl_animation_action.py b/tests/python/bl_animation_action.py index ad5c04e760f..6c88057297b 100644 --- a/tests/python/bl_animation_action.py +++ b/tests/python/bl_animation_action.py @@ -377,203 +377,6 @@ class LimitationsTest(unittest.TestCase): self.assertFalse(hasattr(strip, 'frame_offset')) -class LegacyAPIOnLayeredActionTest(unittest.TestCase): - """Test that the legacy Action API works on layered Actions. - - It should give access to the keyframes for the first slot. - - - curve_frame_range - - fcurves - - groups - - id_root - - flip_with_pose(object) - """ - - def setUp(self) -> None: - bpy.ops.wm.read_homefile(use_factory_startup=True) - - self.action = bpy.data.actions.new('LayeredAction') - - def test_fcurves_on_layered_action(self) -> None: - slot = self.action.slots.new(bpy.data.objects['Cube'].id_type, bpy.data.objects['Cube'].name) - - layer = self.action.layers.new(name="Layer") - strip = layer.strips.new(type='KEYFRAME') - channelbag = strip.channelbags.new(slot=slot) - - # Create new F-Curves via legacy API, they should be stored on the Channelbag. - fcurve1 = self.action.fcurves.new("scale", index=1) - fcurve2 = self.action.fcurves.new("scale", index=2) - self.assertEqual([fcurve1, fcurve2], channelbag.fcurves[:], "Expected two F-Curves after creating them") - self.assertEqual([fcurve1, fcurve2], self.action.fcurves[:], - "Expected the same F-Curves on the legacy API") - - # Find an F-Curve. - self.assertEqual(fcurve2, self.action.fcurves.find("scale", index=2)) - - # Create an already-existing F-Curve. - try: - self.action.fcurves.new("scale", index=2) - except RuntimeError as ex: - self.assertIn("F-Curve 'scale[2]' already exists in action 'LayeredAction'", str(ex)) - else: - self.fail("expected RuntimeError not thrown") - self.assertEqual([fcurve1, fcurve2], channelbag.fcurves[:], - "Expected two F-Curves after failing to create a third") - self.assertEqual([fcurve1, fcurve2], self.action.fcurves[:]) - - # Remove a single F-Curve. - self.action.fcurves.remove(fcurve1) - self.assertEqual([fcurve2], channelbag.fcurves[:], "Expected single F-Curve after removing one") - self.assertEqual([fcurve2], self.action.fcurves[:]) - - # Clear all F-Curves (with multiple F-Curves to avoid the trivial case). - self.action.fcurves.new("scale", index=3) - self.action.fcurves.clear() - self.assertEqual([], channelbag.fcurves[:], "Expected empty fcurves list after clearing") - self.assertEqual([], self.action.fcurves[:]) - - def test_fcurves_clear_should_not_create_layers(self): - self.action.fcurves.clear() - self.assertEqual([], self.action.slots[:]) - self.assertEqual([], self.action.layers[:]) - - def test_fcurves_new_on_empty_action(self) -> None: - # Create new F-Curves via legacy API, this should create a layer+strip+Channelbag. - fcurve1 = self.action.fcurves.new("scale", index=1) - fcurve2 = self.action.fcurves.new("scale", index=2) - - self.assertEqual(1, len(self.action.slots)) - self.assertEqual(1, len(self.action.layers)) - - slot = self.action.slots[0] - layer = self.action.layers[0] - - self.assertEqual("Legacy Slot", slot.name_display) - self.assertEqual("Legacy Layer", layer.name) - - self.assertEqual(1, len(layer.strips)) - strip = layer.strips[0] - self.assertEqual('KEYFRAME', strip.type) - self.assertEqual(1, len(strip.channelbags)) - channelbag = strip.channelbags[0] - self.assertEqual(channelbag.slot_handle, slot.handle) - - self.assertEqual([fcurve1, fcurve2], channelbag.fcurves[:]) - - # After this, there is no need to test the rest of the functions, as the - # Action will be in the same state as in test_fcurves_on_layered_action(). - - def test_groups(self) -> None: - # Create a group by using the legacy API to create an F-Curve with group name. - group_name = "Object Transfoibles" - self.action.fcurves.new("scale", index=1, action_group=group_name) - - layer = self.action.layers[0] - strip = layer.strips[0] - channelbag = strip.channelbags[0] - - self.assertEqual(1, len(channelbag.groups), "The new group should be available on the channelbag") - self.assertEqual(group_name, channelbag.groups[0].name) - self.assertEqual(1, len(self.action.groups), "The new group should be available with the legacy group API") - self.assertEqual(group_name, self.action.groups[0].name) - - # Create a group via the legacy API. - group = self.action.groups.new(group_name) - self.assertEqual("{}.001".format(group_name), group.name, "The group should have a unique name") - self.assertEqual(group, self.action.groups[1], "The group should be accessible via the legacy API") - self.assertEqual(group, channelbag.groups[1], "The group should be accessible via the channelbag") - - # Remove a group via the legacy API. - self.action.groups.remove(group) - self.assertNotIn(group, self.action.groups[:], "A group should be removable via the legacy API") - self.assertNotIn(group, channelbag.groups[:], "A group should be removable via the legacy API") - - def test_groups_new_on_empty_action(self) -> None: - # Create new group via legacy API, this should create a layer+strip+Channelbag. - group = self.action.groups.new("foo") - - self.assertEqual(1, len(self.action.slots)) - self.assertEqual(1, len(self.action.layers)) - - slot = self.action.slots[0] - layer = self.action.layers[0] - - self.assertEqual("Legacy Slot", slot.name_display) - self.assertEqual("Legacy Layer", layer.name) - - self.assertEqual(1, len(layer.strips)) - strip = layer.strips[0] - self.assertEqual('KEYFRAME', strip.type) - self.assertEqual(1, len(strip.channelbags)) - channelbag = strip.channelbags[0] - self.assertEqual(channelbag.slot_handle, slot.handle) - - self.assertEqual([group], channelbag.groups[:]) - - def test_id_root_on_layered_action(self) -> None: - # When there's at least one slot, action.id_root should simply act as a - # proxy for the first slot's target_id_type. This should work for both - # reading and writing. - - slot_1 = self.action.slots.new('OBJECT', "Slot 1") - slot_2 = self.action.slots.new('CAMERA', "Slot 2") - bpy.data.objects['Cube'].animation_data_create() - bpy.data.objects['Cube'].animation_data.action = self.action - bpy.data.objects['Cube'].animation_data.action_slot = slot_1 - - self.assertEqual(self.action.id_root, 'OBJECT') - self.assertEqual(self.action.slots[0].target_id_type, 'OBJECT') - self.assertEqual(self.action.slots[0].identifier, 'OBSlot 1') - self.assertEqual(self.action.slots[1].target_id_type, 'CAMERA') - self.assertEqual(self.action.slots[1].identifier, 'CASlot 2') - self.assertEqual(bpy.data.objects['Cube'].animation_data.last_slot_identifier, 'OBSlot 1') - - self.action.id_root = 'MATERIAL' - - self.assertEqual(self.action.id_root, 'MATERIAL') - self.assertEqual(self.action.slots[0].target_id_type, 'MATERIAL') - self.assertEqual(self.action.slots[0].identifier, 'MASlot 1') - self.assertEqual(self.action.slots[1].target_id_type, 'CAMERA') - self.assertEqual(self.action.slots[1].identifier, 'CASlot 2') - self.assertEqual(bpy.data.objects['Cube'].animation_data.last_slot_identifier, 'MASlot 1') - - def test_id_root_on_layered_action_for_identifier_uniqueness(self) -> None: - # When setting id_root such that the first slot's identifier would - # become a duplicate, the name portion of the identifier should be - # automatically renamed to be unique. - - slot_1 = self.action.slots.new('OBJECT', "Foo") - slot_2 = self.action.slots.new('CAMERA', "Foo") - - self.assertEqual(self.action.id_root, 'OBJECT') - self.assertEqual(self.action.slots[0].target_id_type, 'OBJECT') - self.assertEqual(self.action.slots[0].identifier, 'OBFoo') - self.assertEqual(self.action.slots[1].target_id_type, 'CAMERA') - self.assertEqual(self.action.slots[1].identifier, 'CAFoo') - - self.action.id_root = 'CAMERA' - - self.assertEqual(self.action.id_root, 'CAMERA') - self.assertEqual(self.action.slots[0].target_id_type, 'CAMERA') - self.assertEqual(self.action.slots[0].identifier, 'CAFoo.001') - self.assertEqual(self.action.slots[1].target_id_type, 'CAMERA') - self.assertEqual(self.action.slots[1].identifier, 'CAFoo') - - def test_id_root_on_empty_action(self) -> None: - # When there are no slots, setting action.id_root should create a legacy - # slot and set its target_id_type. - - self.assertEqual(self.action.id_root, 'UNSPECIFIED') - self.assertEqual(len(self.action.slots), 0) - - self.action.id_root = 'OBJECT' - - self.assertEqual(self.action.id_root, 'OBJECT') - self.assertEqual(len(self.action.slots), 1) - self.assertEqual(self.action.slots[0].target_id_type, 'OBJECT') - - class ChannelbagsTest(unittest.TestCase): def setUp(self): anims = bpy.data.actions diff --git a/tests/python/bl_animation_fcurves.py b/tests/python/bl_animation_fcurves.py index 77f75a5bdc7..af61f9abdd7 100644 --- a/tests/python/bl_animation_fcurves.py +++ b/tests/python/bl_animation_fcurves.py @@ -20,16 +20,32 @@ class AbstractAnimationTest: cls.testdir = args.testdir def setUp(self): + assert isinstance(self, unittest.TestCase) self.assertTrue(self.testdir.exists(), 'Test dir %s should exist' % self.testdir) +def _channelbag(animated_id: bpy.types.ID) -> bpy.types.ActionChannelbag: + """Return the first layer's Channelbag of the animated ID's Action.""" + action = animated_id.animation_data.action + action_slot = animated_id.animation_data.action_slot + channelbag = action.layers[0].strips[0].channelbag(action_slot) + assert channelbag is not None + return channelbag + + +def _first_fcurve(animated_id: bpy.types.ID) -> bpy.types.FCurve: + """Return the first F-Curve of the animated ID's Action.""" + return _channelbag(animated_id).fcurves[0] + + class FCurveEvaluationTest(AbstractAnimationTest, unittest.TestCase): def test_fcurve_versioning_291(self): # See D8752. bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "fcurve-versioning-291.blend")) cube = bpy.data.objects['Cube'] - fcurve = cube.animation_data.action.fcurves.find('location', index=0) + channelbag = cube.animation_data.action.layers[0].strips[0].channelbags[0] + fcurve = channelbag.fcurves.find('location', index=0) self.assertAlmostEqual(0.0, fcurve.evaluate(1)) self.assertAlmostEqual(0.019638698548078537, fcurve.evaluate(2)) @@ -46,7 +62,8 @@ class FCurveEvaluationTest(AbstractAnimationTest, unittest.TestCase): # See D8752. bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "fcurve-extreme-handles.blend")) cube = bpy.data.objects['Cube'] - fcurve = cube.animation_data.action.fcurves.find('location', index=0) + channelbag = cube.animation_data.action.layers[0].strips[0].channelbags[0] + fcurve = channelbag.fcurves.find('location', index=0) self.assertAlmostEqual(0.0, fcurve.evaluate(1)) self.assertAlmostEqual(0.004713400732725859, fcurve.evaluate(2)) @@ -66,11 +83,6 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): This tests both the evaluation of the RNA property and the F-Curve interpolation itself (the not-exposed-to-RNA flags `FCURVE_INT_VALUES` and `FCURVE_DISCRETE_VALUES` have an impact on the latter as well). - - NOTE: This test uses the backward-compatible API in 4.4 (action.fcurves) - because it only uses a single slot anyway. This way, the test is - backward-compatible with older versions of Blender, and can be used to track - down regression issues. """ def setUp(self): @@ -87,7 +99,7 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): camera.keyframe_insert('lens', frame=64) self._make_all_keys_linear() - fcurve = camera.animation_data.action.fcurves[0] + fcurve = _first_fcurve(camera) scene.frame_set(0) self.assertAlmostEqual(16, camera.lens) @@ -115,7 +127,7 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): render.keyframe_insert('simplify_subdivision', frame=64) self._make_all_keys_linear() - fcurve = scene.animation_data.action.fcurves[0] + fcurve = _first_fcurve(scene) scene.frame_set(0) self.assertAlmostEqual(16, render.simplify_subdivision) @@ -143,7 +155,7 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): render.keyframe_insert('use_simplify', frame=64) self._make_all_keys_linear() - fcurve = scene.animation_data.action.fcurves[0] + fcurve = _first_fcurve(scene) scene.frame_set(0) self.assertEqual(False, render.use_simplify) @@ -170,7 +182,7 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): cube.keyframe_insert('rotation_mode', frame=64) self._make_all_keys_linear() - fcurve = cube.animation_data.action.fcurves[0] + fcurve = _first_fcurve(cube) scene.frame_set(0) self.assertEqual('QUATERNION', cube.rotation_mode) @@ -192,13 +204,13 @@ class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase): """ for action in bpy.data.actions: - # Make this test backward compatible with older versions of Blender, - # to make it easier to test regressions. - self.assertEqual(1, len(action.slots), f"{action} should have exactly one slot") - - for fcurve in action.fcurves: - for key in fcurve.keyframe_points: - key.interpolation = 'LINEAR' + for layer in action.layers: + for strip in layer.strips: + self.assertEqual(strip.type, 'KEYFRAME') + for channelbag in strip.channelbags: + for fcurve in channelbag.fcurves: + for key in fcurve.keyframe_points: + key.interpolation = 'LINEAR' class EulerFilterTest(AbstractAnimationTest, unittest.TestCase): @@ -290,8 +302,8 @@ class EulerFilterTest(AbstractAnimationTest, unittest.TestCase): @staticmethod def active_object_rotation_channels() -> list[bpy.types.FCurve]: ob = bpy.context.view_layer.objects.active - action = ob.animation_data.action - return [action.fcurves.find('rotation_euler', index=idx) for idx in range(3)] + channelbag = _channelbag(ob) + return [channelbag.fcurves.find('rotation_euler', index=idx) for idx in range(3)] def get_view3d_context(): @@ -322,8 +334,9 @@ class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") key_object = bpy.context.active_object + fcurve = _first_fcurve(key_object) for key_index in range(key_count): - key = key_object.animation_data.action.fcurves[0].keyframe_points[key_index] + key = fcurve.keyframe_points[key_index] self.assertEqual(key.co.x, key_index) bpy.ops.object.delete(use_global=False) @@ -339,7 +352,7 @@ class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase): key_object.keyframe_insert("rotation_euler", keytype='UNSUPPORTED') # Only a single key should have been inserted. - keys = key_object.animation_data.action.fcurves[0].keyframe_points + keys = _first_fcurve(key_object).keyframe_points self.assertEqual(len(keys), 1) self.assertEqual(keys[0].type, 'GENERATED') @@ -354,7 +367,7 @@ class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase): key_object = bpy.context.active_object for key_index in range(key_count): - key = key_object.animation_data.action.fcurves[0].keyframe_points[key_index] + key = _first_fcurve(key_object).keyframe_points[key_index] self.assertEqual(key.co.x, key_index + frame_offset) bpy.ops.object.delete(use_global=False) @@ -369,7 +382,7 @@ class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase): key_object = bpy.context.active_object for key_index in range(key_count): - key = key_object.animation_data.action.fcurves[0].keyframe_points[key_index] + key = _first_fcurve(key_object).keyframe_points[key_index] self.assertAlmostEqual(key.co.x, key_index / key_count) bpy.ops.object.delete(use_global=False) @@ -405,7 +418,7 @@ class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase): # Even though range() is exclusive, the floating point limitations mean keys end up on that position. 1000001.0 ] - keyframe_points = key_object.animation_data.action.fcurves[0].keyframe_points + keyframe_points = _first_fcurve(key_object).keyframe_points for i, value in enumerate(floating_point_steps): key = keyframe_points[i] self.assertAlmostEqual(key.co.x, value) @@ -429,7 +442,7 @@ class KeyframeDeleteTest(AbstractAnimationTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") key_object = bpy.context.active_object - fcu = key_object.animation_data.action.fcurves[0] + fcu = _first_fcurve(key_object) for i in range(key_count): fcu.keyframe_points.insert(frame=i, value=0) @@ -452,7 +465,7 @@ class KeyframeDeleteTest(AbstractAnimationTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") key_object = bpy.context.active_object - fcu = key_object.animation_data.action.fcurves[0] + fcu = _first_fcurve(key_object) for i in range(key_count): fcu.keyframe_points.insert(frame=i + frame_offset, value=0) @@ -474,7 +487,7 @@ class KeyframeDeleteTest(AbstractAnimationTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") key_object = bpy.context.active_object - fcu = key_object.animation_data.action.fcurves[0] + fcu = _first_fcurve(key_object) for i in range(key_count): fcu.keyframe_points.insert(frame=i / key_count, value=0) @@ -497,7 +510,7 @@ class KeyframeDeleteTest(AbstractAnimationTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") key_object = bpy.context.active_object - fcu = key_object.animation_data.action.fcurves[0] + fcu = _first_fcurve(key_object) for i in range(key_count): fcu.keyframe_points.insert(frame=i / key_count + frame_offset, value=0) diff --git a/tests/python/bl_animation_keyframing.py b/tests/python/bl_animation_keyframing.py index b02c813b4ce..adee2065b15 100644 --- a/tests/python/bl_animation_keyframing.py +++ b/tests/python/bl_animation_keyframing.py @@ -13,7 +13,7 @@ blender -b --factory-startup --python tests/python/bl_animation_keyframing.py -- """ -def _fcurve_paths_match(fcurves: list, expected_paths: list) -> bool: +def _fcurve_paths_match(fcurves: list[bpy.types.FCurve], expected_paths: list[str]) -> None: data_paths = list(set([fcurve.data_path for fcurve in fcurves])) data_paths.sort() expected_paths.sort() @@ -22,6 +22,21 @@ def _fcurve_paths_match(fcurves: list, expected_paths: list) -> bool: f"Expected paths do not match F-Curve paths. Expected: {expected_paths}. F-Curve: {data_paths}") +def _first_channelbag(action: bpy.types.Action) -> bpy.types.ActionChannelbag: + """Return the first Channelbag of the Action.""" + assert isinstance(action, bpy.types.Action), f"Expected Action, got {action!r}" + return action.layers[0].strips[0].channelbags[0] + + +def _channelbag(animated_id: bpy.types.ID) -> bpy.types.ActionChannelbag: + """Return the first layer's Channelbag of the animated ID's Action.""" + action = animated_id.animation_data.action + action_slot = animated_id.animation_data.action_slot + channelbag = action.layers[0].strips[0].channelbag(action_slot) + assert channelbag is not None + return channelbag + + def _get_view3d_context(): ctx = bpy.context.copy() @@ -86,7 +101,7 @@ def _insert_by_name_test(insert_key: str, expected_paths: list): keyed_object = _create_animation_object() with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert_by_name(type=insert_key) - _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths) + _fcurve_paths_match(_channelbag(keyed_object).fcurves, expected_paths) bpy.data.objects.remove(keyed_object, do_unlink=True) @@ -95,7 +110,7 @@ def _insert_from_user_preference_test(enabled_user_pref_fields: set, expected_pa bpy.context.preferences.edit.key_insert_channels = enabled_user_pref_fields with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert() - _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths) + _fcurve_paths_match(_channelbag(keyed_object).fcurves, expected_paths) bpy.data.objects.remove(keyed_object, do_unlink=True) @@ -110,7 +125,7 @@ def _insert_with_keying_set_test(keying_set_name: str, expected_paths: list): keyed_object = _create_animation_object() with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert() - _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths) + _fcurve_paths_match(_channelbag(keyed_object).fcurves, expected_paths) bpy.data.objects.remove(keyed_object, do_unlink=True) @@ -148,18 +163,20 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert() + channelbag = _channelbag(keyed_object) + # Check the F-Curves paths. expect_paths = ["location", "location", "location"] - actual_paths = [fcurve.data_path for fcurve in keyed_object.animation_data.action.fcurves] + actual_paths = [fcurve.data_path for fcurve in channelbag.fcurves] self.assertEqual(actual_paths, expect_paths) # The actual reason for this test: check that these curves have the right group. expect_groups = ["Object Transforms"] - actual_groups = [group.name for group in keyed_object.animation_data.action.groups] + actual_groups = [group.name for group in channelbag.groups] self.assertEqual(actual_groups, expect_groups) - expect_groups = 3 * [keyed_object.animation_data.action.groups[0]] - actual_groups = [fcurve.group for fcurve in keyed_object.animation_data.action.fcurves] + expect_groups = 3 * [channelbag.groups[0]] + actual_groups = [fcurve.group for fcurve in channelbag.fcurves] self.assertEqual(actual_groups, expect_groups) def test_insert_custom_properties(self): @@ -196,7 +213,7 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.anim.keyframe_insert() keyed_rna_paths = [f"[\"{bpy.utils.escape_identifier(path)}\"]" for path in keyed_properties.keys()] - _fcurve_paths_match(keyed_object.animation_data.action.fcurves, keyed_rna_paths) + _fcurve_paths_match(_channelbag(keyed_object).fcurves, keyed_rna_paths) bpy.data.objects.remove(keyed_object, do_unlink=True) def test_key_selection_state(self): @@ -207,7 +224,7 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): bpy.context.scene.frame_set(5) bpy.ops.anim.keyframe_insert() - for fcurve in keyed_object.animation_data.action.fcurves: + for fcurve in _channelbag(keyed_object).fcurves: self.assertEqual(len(fcurve.keyframe_points), 2) self.assertFalse(fcurve.keyframe_points[0].select_control_point) self.assertTrue(fcurve.keyframe_points[1].select_control_point) @@ -218,7 +235,7 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): # Test on location, which is a 3-item array, without explicitly passing an array index. self.assertTrue(curve_object.keyframe_insert('location')) - ob_fcurves = curve_object.animation_data.action.fcurves + ob_fcurves = _channelbag(curve_object).fcurves self.assertEqual(len(ob_fcurves), 3, "Keying 'location' without any array index should have created 3 F-Curves") @@ -249,8 +266,9 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): # Test with property for which Blender knows a group name too ('Object Transforms'). self.assertTrue(curve_object.keyframe_insert('location', group="Téšt")) - fcurves = curve_object.animation_data.action.fcurves - fgroups = curve_object.animation_data.action.groups + channelbag = _channelbag(curve_object) + fcurves = channelbag.fcurves + fgroups = channelbag.groups self.assertEqual(3 * ['location'], [fcurve.data_path for fcurve in fcurves]) self.assertEqual([0, 1, 2], [fcurve.array_index for fcurve in fcurves]) @@ -271,7 +289,7 @@ class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase): obj = bpy.context.object obj.data.attributes.new("test", "FLOAT", "POINT") self.assertTrue(obj.data.keyframe_insert('attributes["test"].data[0].value')) - fcurves = obj.data.animation_data.action.fcurves + fcurves = _channelbag(obj.data).fcurves self.assertEqual(len(fcurves), 1) self.assertEqual(fcurves[0].data_path, 'attributes["test"].data[0].value') @@ -294,7 +312,7 @@ class VisualKeyingTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert_by_name(type="BUILTIN_KSI_VisualLoc") - for fcurve in constrained.animation_data.action.fcurves: + for fcurve in _channelbag(constrained).fcurves: self.assertEqual(fcurve.keyframe_points[0].co.y, t_value) def test_visual_rotation_keying_set(self): @@ -310,7 +328,7 @@ class VisualKeyingTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert_by_name(type="BUILTIN_KSI_VisualRot") - for fcurve in constrained.animation_data.action.fcurves: + for fcurve in _channelbag(constrained).fcurves: self.assertAlmostEqual(fcurve.keyframe_points[0].co.y, rot_value_rads, places=4) def test_visual_location_user_pref_override(self): @@ -327,7 +345,7 @@ class VisualKeyingTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert_by_name(type="Location") - for fcurve in constrained.animation_data.action.fcurves: + for fcurve in _channelbag(constrained).fcurves: self.assertEqual(fcurve.keyframe_points[0].co.y, t_value) def test_visual_location_user_pref(self): @@ -344,7 +362,7 @@ class VisualKeyingTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_insert() - for fcurve in constrained.animation_data.action.fcurves: + for fcurve in _channelbag(constrained).fcurves: self.assertEqual(fcurve.keyframe_points[0].co.y, t_value) @@ -391,11 +409,12 @@ class CycleAwareKeyingTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") # Check that only location keys have been created. - _fcurve_paths_match(action.fcurves, ["location"]) + channelbag = action.layers[0].strips[0].channelbags[0] + _fcurve_paths_match(channelbag.fcurves, ["location"]) expected_keys = [1.0, 3.0, 5.0, 9.0, 20.0] - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: actual_keys = [key.co.x for key in fcurve.keyframe_points] self.assertEqual(expected_keys, actual_keys) @@ -425,7 +444,8 @@ class CycleAwareKeyingTest(AbstractKeyframingTest, unittest.TestCase): expected_keys = [1.0, 3.0, 5.0, 20.0] - for fcurve in action.fcurves: + channelbag = action.layers[0].strips[0].channelbags[0] + for fcurve in channelbag.fcurves: actual_keys = [key.co.x for key in fcurve.keyframe_points] self.assertEqual(expected_keys, actual_keys) @@ -451,8 +471,8 @@ class AutoKeyframingTest(AbstractKeyframingTest, unittest.TestCase): with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.transform.translate(value=(1, 0, 0)) - action = keyed_object.animation_data.action - _fcurve_paths_match(action.fcurves, ["location", "rotation_euler", "scale"]) + channelbag = _channelbag(keyed_object) + _fcurve_paths_match(channelbag.fcurves, ["location", "rotation_euler", "scale"]) def test_autokey_bone(self): armature_obj = _create_armature() @@ -463,10 +483,10 @@ class AutoKeyframingTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.transform.translate(value=(1, 0, 0)) bpy.ops.object.mode_set(mode='OBJECT') - action = armature_obj.animation_data.action + channelbag = _channelbag(armature_obj) bone_path = f"pose.bones[\"{_BONE_NAME}\"]" expected_paths = [f"{bone_path}.location", f"{bone_path}.rotation_euler", f"{bone_path}.scale"] - _fcurve_paths_match(action.fcurves, expected_paths) + _fcurve_paths_match(channelbag.fcurves, expected_paths) def test_key_selection_state(self): armature_obj = _create_armature() @@ -476,8 +496,8 @@ class AutoKeyframingTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.transform.translate(value=(0, 1, 0)) bpy.ops.object.mode_set(mode='OBJECT') - action = armature_obj.animation_data.action - for fcurve in action.fcurves: + channelbag = _channelbag(armature_obj) + for fcurve in channelbag.fcurves: self.assertEqual(len(fcurve.keyframe_points), 2) self.assertFalse(fcurve.keyframe_points[0].select_control_point) self.assertTrue(fcurve.keyframe_points[1].select_control_point) @@ -507,8 +527,8 @@ class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.transform.translate(value=(1, 0, 0)) # Test that no new keyframes have been added. - action = keyed_object.animation_data.action - _fcurve_paths_match(action.fcurves, ["rotation_euler"]) + channelbag = _channelbag(keyed_object) + _fcurve_paths_match(channelbag.fcurves, ["rotation_euler"]) with bpy.context.temp_override(**_get_view3d_context()): bpy.context.scene.frame_set(1) @@ -516,10 +536,10 @@ class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase): bpy.context.scene.frame_set(5) bpy.ops.transform.translate(value=(1, 0, 0)) - action = keyed_object.animation_data.action - _fcurve_paths_match(action.fcurves, ["location", "rotation_euler"]) + channelbag = _channelbag(keyed_object) + _fcurve_paths_match(channelbag.fcurves, ["location", "rotation_euler"]) - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: # Translating the bone would also add rotation keys as long as "Only Insert Needed" is off. if "location" in fcurve.data_path or "rotation" in fcurve.data_path: self.assertEqual(len(fcurve.keyframe_points), 2) @@ -537,10 +557,10 @@ class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.transform.translate(value=(1, 0, 0)) # Test that no new keyframes have been added. - action = armature_obj.animation_data.action + channelbag = _channelbag(armature_obj) bone_path = f"pose.bones[\"{_BONE_NAME}\"]" expected_paths = [f"{bone_path}.rotation_euler"] - _fcurve_paths_match(action.fcurves, expected_paths) + _fcurve_paths_match(channelbag.fcurves, expected_paths) with bpy.context.temp_override(**_get_view3d_context()): bpy.context.scene.frame_set(1) @@ -549,9 +569,9 @@ class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.transform.translate(value=(1, 0, 0)) expected_paths = [f"{bone_path}.location", f"{bone_path}.rotation_euler"] - _fcurve_paths_match(action.fcurves, expected_paths) + _fcurve_paths_match(channelbag.fcurves, expected_paths) - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: # Translating the bone would also add rotation keys as long as "Only Insert Needed" is off. if "location" in fcurve.data_path or "rotation" in fcurve.data_path: self.assertEqual(len(fcurve.keyframe_points), 2) @@ -572,10 +592,10 @@ class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase): bpy.context.scene.frame_set(5) bpy.ops.anim.keyframe_insert_by_name(type="Available") - action = keyed_object.animation_data.action - _fcurve_paths_match(action.fcurves, ["location"]) + channelbag = _channelbag(keyed_object) + _fcurve_paths_match(channelbag.fcurves, ["location"]) - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: self.assertEqual(len(fcurve.keyframe_points), 2) def test_insert_available(self): @@ -610,8 +630,8 @@ class InsertNeededTest(AbstractKeyframingTest, unittest.TestCase): bpy.context.scene.frame_set(5) bpy.ops.transform.translate(value=(1, 0, 0)) - action = keyed_object.animation_data.action - _fcurve_paths_match(action.fcurves, ["location"]) + channelbag = _channelbag(keyed_object) + _fcurve_paths_match(channelbag.fcurves, ["location"]) # With "Insert Needed" enabled it has to key all location channels first, # before it can add keys only to the channels where values have actually @@ -620,9 +640,9 @@ class InsertNeededTest(AbstractKeyframingTest, unittest.TestCase): "location": (2, 1, 1) } - self.assertEqual(len(action.fcurves), 3) + self.assertEqual(len(channelbag.fcurves), 3) - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: if fcurve.data_path not in expected_keys: raise AssertionError(f"Did not expect a key on {fcurve.data_path}") self.assertEqual(expected_keys[fcurve.data_path][fcurve.array_index], len(fcurve.keyframe_points)) @@ -639,9 +659,9 @@ class InsertNeededTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.object.mode_set(mode='OBJECT') - action = armature_obj.animation_data.action + channelbag = _channelbag(armature_obj) bone_path = f"pose.bones[\"{_BONE_NAME}\"]" - _fcurve_paths_match(action.fcurves, [f"{bone_path}.location"]) + _fcurve_paths_match(channelbag.fcurves, [f"{bone_path}.location"]) # With "Insert Needed" enabled it has to key all location channels first, # before it can add keys only to the channels where values have actually @@ -650,9 +670,9 @@ class InsertNeededTest(AbstractKeyframingTest, unittest.TestCase): f"{bone_path}.location": (2, 1, 1) } - self.assertEqual(len(action.fcurves), 3) + self.assertEqual(len(channelbag.fcurves), 3) - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: if fcurve.data_path not in expected_keys: raise AssertionError(f"Did not expect a key on {fcurve.data_path}") self.assertEqual(expected_keys[fcurve.data_path][fcurve.array_index], len(fcurve.keyframe_points)) @@ -668,7 +688,19 @@ def _create_nla_anim_object(): add: 0, 1 base: 0, 1 """ + anim_object = bpy.data.objects.new("anim_object", None) + + def _ensure_fcurve(action: bpy.types.Action, *, data_path: str, index: int) -> bpy.types.FCurve: + # Briefly directly assign the Action so that Blender knows what to do. + anim_object.animation_data_create().action = action + try: + fcurve = action.fcurve_ensure_for_datablock(anim_object, data_path=data_path, index=index) + finally: + anim_object.animation_data.action = None + + return fcurve + bpy.context.scene.collection.objects.link(anim_object) bpy.context.view_layer.objects.active = anim_object anim_object.select_set(True) @@ -677,7 +709,7 @@ def _create_nla_anim_object(): track = anim_object.animation_data.nla_tracks.new() track.name = "base" action_base = bpy.data.actions.new(name="action_base") - fcu = action_base.fcurves.new(data_path="location", index=0) + fcu = _ensure_fcurve(action_base, data_path="location", index=0) fcu.keyframe_points.insert(0, value=0).interpolation = 'LINEAR' fcu.keyframe_points.insert(10, value=1).interpolation = 'LINEAR' track.strips.new("base_strip", 0, action_base) @@ -686,7 +718,7 @@ def _create_nla_anim_object(): track = anim_object.animation_data.nla_tracks.new() track.name = "add" action_add = bpy.data.actions.new(name="action_add") - fcu = action_add.fcurves.new(data_path="location", index=0) + fcu = _ensure_fcurve(action_add, data_path="location", index=0) fcu.keyframe_points.insert(0, value=0).interpolation = 'LINEAR' fcu.keyframe_points.insert(10, value=1).interpolation = 'LINEAR' strip = track.strips.new("add_strip", 0, action_add) @@ -696,7 +728,7 @@ def _create_nla_anim_object(): track = anim_object.animation_data.nla_tracks.new() track.name = "top" action_top = bpy.data.actions.new(name="action_top") - fcu = action_top.fcurves.new(data_path="location", index=0) + fcu = _ensure_fcurve(action_top, data_path="location", index=0) fcu.keyframe_points.insert(0, value=0).interpolation = 'LINEAR' fcu.keyframe_points.insert(10, value=0).interpolation = 'LINEAR' track.strips.new("top_strip", 0, action_top) @@ -744,12 +776,13 @@ class NlaInsertTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.anim.keyframe_insert() base_action = bpy.data.actions["action_base"] + channelbag = _first_channelbag(base_action) # Location X should not have been able to insert a keyframe because the top strip is overriding the result completely, # making it impossible to calculate which value should be inserted. - self.assertEqual(len(base_action.fcurves.find("location", index=0).keyframe_points), 2) + self.assertEqual(len(channelbag.fcurves.find("location", index=0).keyframe_points), 2) # Location Y and Z will go through since they have not been defined in the action of the top strip. - self.assertEqual(len(base_action.fcurves.find("location", index=1).keyframe_points), 1) - self.assertEqual(len(base_action.fcurves.find("location", index=2).keyframe_points), 1) + self.assertEqual(len(channelbag.fcurves.find("location", index=1).keyframe_points), 1) + self.assertEqual(len(channelbag.fcurves.find("location", index=2).keyframe_points), 1) def test_insert_additive(self): nla_anim_object = _create_nla_anim_object() @@ -778,7 +811,8 @@ class NlaInsertTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.anim.keyframe_insert() # Check that the expected F-Curves exist. - fcurves_actual = {(f.data_path, f.array_index) for f in base_action.fcurves} + channelbag = _first_channelbag(base_action) + fcurves_actual = {(f.data_path, f.array_index) for f in channelbag.fcurves} fcurves_expect = { ("location", 0), ("location", 1), @@ -788,14 +822,14 @@ class NlaInsertTest(AbstractKeyframingTest, unittest.TestCase): # This should have added keys to Y and Z but not X. # X already had two keys from the file setup. - self.assertEqual(len(base_action.fcurves.find("location", index=0).keyframe_points), 2) - self.assertEqual(len(base_action.fcurves.find("location", index=1).keyframe_points), 1) - self.assertEqual(len(base_action.fcurves.find("location", index=2).keyframe_points), 1) + self.assertEqual(len(channelbag.fcurves.find("location", index=0).keyframe_points), 2) + self.assertEqual(len(channelbag.fcurves.find("location", index=1).keyframe_points), 1) + self.assertEqual(len(channelbag.fcurves.find("location", index=2).keyframe_points), 1) # The keyframe value should not be changed even though the position of the # object is modified by the additive layer. self.assertAlmostEqual(nla_anim_object.location.x, 2.0, 8) - fcurve_loc_x = base_action.fcurves.find("location", index=0) + fcurve_loc_x = channelbag.fcurves.find("location", index=0) self.assertAlmostEqual(fcurve_loc_x.keyframe_points[-1].co[1], 1.0, 8) @@ -808,8 +842,8 @@ class KeyframeDeleteTest(AbstractKeyframingTest, unittest.TestCase): bpy.ops.anim.keyframe_insert_by_name(type="Location") self.assertTrue(armature.animation_data is not None) self.assertTrue(armature.animation_data.action is not None) - action = armature.animation_data.action - self.assertEqual(len(action.fcurves), 3) + channelbag = _channelbag(armature) + self.assertEqual(len(channelbag.fcurves), 3) bpy.ops.object.mode_set(mode='POSE') with bpy.context.temp_override(**_get_view3d_context()): @@ -817,22 +851,22 @@ class KeyframeDeleteTest(AbstractKeyframingTest, unittest.TestCase): bpy.context.scene.frame_set(5) bpy.ops.anim.keyframe_insert_by_name(type="Location") # This should have added new FCurves for the pose bone. - self.assertEqual(len(action.fcurves), 6) + self.assertEqual(len(channelbag.fcurves), 6) bpy.ops.anim.keyframe_delete_v3d() # No Fcurves should yet be deleted. - self.assertEqual(len(action.fcurves), 6) - self.assertEqual(len(action.fcurves[0].keyframe_points), 1) + self.assertEqual(len(channelbag.fcurves), 6) + self.assertEqual(len(channelbag.fcurves[0].keyframe_points), 1) bpy.context.scene.frame_set(1) bpy.ops.anim.keyframe_delete_v3d() # This should leave the object level keyframes of the armature - self.assertEqual(len(action.fcurves), 3) + self.assertEqual(len(channelbag.fcurves), 3) bpy.ops.object.mode_set(mode='OBJECT') with bpy.context.temp_override(**_get_view3d_context()): bpy.ops.anim.keyframe_delete_v3d() # The last FCurves should be deleted from the object now. - self.assertEqual(len(action.fcurves), 0) + self.assertEqual(len(channelbag.fcurves), 0) def main(): diff --git a/tests/python/bl_animation_nla_strip.py b/tests/python/bl_animation_nla_strip.py index c725363a859..06fefce5a17 100644 --- a/tests/python/bl_animation_nla_strip.py +++ b/tests/python/bl_animation_nla_strip.py @@ -35,7 +35,12 @@ class AbstractNlaStripTest(unittest.TestCase): self.nla_tracks = self.test_object.animation_data.nla_tracks self.action = bpy.data.actions.new(name="ObjectAction") - x_location_fcurve = self.action.fcurves.new(data_path="location", index=0, action_group="Object Transforms") + slot = self.action.slots.new(self.test_object.id_type, self.test_object.name) + layer = self.action.layers.new("Layer") + strip = layer.strips.new(type="KEYFRAME") + channelbag = strip.channelbags.new(slot) + + x_location_fcurve = channelbag.fcurves.new(data_path="location", index=0, group_name="Object Transforms") for frame in range(1, 5): x_location_fcurve.keyframe_points.insert(frame, value=frame).interpolation = "CONSTANT" diff --git a/tests/python/bl_pose_assets.py b/tests/python/bl_pose_assets.py index e558a9f36fe..437835f2c25 100644 --- a/tests/python/bl_pose_assets.py +++ b/tests/python/bl_pose_assets.py @@ -52,6 +52,12 @@ def _create_armature(): return armature_obj +def _first_channelbag(action: bpy.types.Action) -> bpy.types.ActionChannelbag: + """Return the first Channelbag of the Action.""" + assert isinstance(action, bpy.types.Action) + return action.layers[0].strips[0].channelbags[0] + + class CreateAssetTest(unittest.TestCase): _library_folder = None @@ -109,8 +115,9 @@ class CreateAssetTest(unittest.TestCase): # string_test is not here because it should not be keyed. } expected_pose_values.update(_BBONE_VALUES) - self.assertEqual(len(pose_action.fcurves), 26) - for fcurve in pose_action.fcurves: + pose_channelbag = _first_channelbag(pose_action) + self.assertEqual(len(pose_channelbag.fcurves), 26) + for fcurve in pose_channelbag.fcurves: self.assertTrue( fcurve.data_path in expected_pose_values, "Only the selected bone should be in the pose asset") @@ -158,8 +165,9 @@ class CreateAssetTest(unittest.TestCase): # string_test is not here because it should not be keyed. } expected_pose_values.update(_BBONE_VALUES) - self.assertEqual(len(pose_action.fcurves), 26) - for fcurve in pose_action.fcurves: + pose_channelbag = _first_channelbag(pose_action) + self.assertEqual(len(pose_channelbag.fcurves), 26) + for fcurve in pose_channelbag.fcurves: self.assertTrue( fcurve.data_path in expected_pose_values, "Only the selected bone should be in the pose asset") @@ -193,8 +201,9 @@ class CreateAssetTest(unittest.TestCase): # The custom properties are not keyed, thus they should not be in the pose asset. } expected_pose_values.update(_BBONE_VALUES) - self.assertEqual(len(pose_action.fcurves), 24) - for fcurve in pose_action.fcurves: + pose_channelbag = _first_channelbag(pose_action) + self.assertEqual(len(pose_channelbag.fcurves), 24) + for fcurve in pose_channelbag.fcurves: self.assertTrue( fcurve.data_path in expected_pose_values, "Only the selected bone should be in the pose asset") diff --git a/tests/python/bl_usd_import_test.py b/tests/python/bl_usd_import_test.py index 98a54774640..e1b8203f567 100644 --- a/tests/python/bl_usd_import_test.py +++ b/tests/python/bl_usd_import_test.py @@ -874,30 +874,31 @@ class USDImportTest(AbstractUSDTest): 8 + i), 1.0, 2, "Unexpected weight for Elbow deform vert") action = bpy.data.actions['Anim1'] + channelbag = action.layers[0].strips[0].channelbags[0] # Verify the Elbow joint rotation animation. curve_path = 'pose.bones["Elbow"].rotation_quaternion' # Quat W - f = action.fcurves.find(curve_path, index=0) + f = channelbag.fcurves.find(curve_path, index=0) self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion W curve") self.assertAlmostEqual(f.evaluate(0), 1.0, 2, "Unexpected value for rotation quaternion W curve at frame 0") self.assertAlmostEqual(f.evaluate(10), 0.707, 2, "Unexpected value for rotation quaternion W curve at frame 10") # Quat X - f = action.fcurves.find(curve_path, index=1) + f = channelbag.fcurves.find(curve_path, index=1) self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion X curve") self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion X curve at frame 0") self.assertAlmostEqual(f.evaluate(10), 0.707, 2, "Unexpected value for rotation quaternion X curve at frame 10") # Quat Y - f = action.fcurves.find(curve_path, index=2) + f = channelbag.fcurves.find(curve_path, index=2) self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion Y curve") self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion Y curve at frame 0") self.assertAlmostEqual(f.evaluate(10), 0.0, 2, "Unexpected value for rotation quaternion Y curve at frame 10") # Quat Z - f = action.fcurves.find(curve_path, index=3) + f = channelbag.fcurves.find(curve_path, index=3) self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion Z curve") self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion Z curve at frame 0") self.assertAlmostEqual(f.evaluate(10), 0.0, 2, "Unexpected value for rotation quaternion Z curve at frame 10")