Files
test/tests/python/bl_animation_fcurves.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

550 lines
22 KiB
Python
Raw Normal View History

# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
blender -b --factory-startup --python tests/python/bl_animation_fcurves.py -- --testdir tests/files/animation
"""
import pathlib
import sys
import unittest
from math import degrees, radians
import bpy
2020-10-02 10:10:01 +10:00
class AbstractAnimationTest:
@classmethod
def setUpClass(cls):
cls.testdir = args.testdir
2020-10-02 09:48:41 +10:00
def setUp(self):
assert isinstance(self, unittest.TestCase)
self.assertTrue(self.testdir.exists(),
'Test dir %s should exist' % self.testdir)
2021-07-06 12:05:27 +10:00
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']
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))
self.assertAlmostEqual(0.0878235399723053, fcurve.evaluate(3))
self.assertAlmostEqual(0.21927043795585632, fcurve.evaluate(4))
self.assertAlmostEqual(0.41515052318573, fcurve.evaluate(5))
self.assertAlmostEqual(0.6332430243492126, fcurve.evaluate(6))
self.assertAlmostEqual(0.8106040954589844, fcurve.evaluate(7))
self.assertAlmostEqual(0.924369215965271, fcurve.evaluate(8))
self.assertAlmostEqual(0.9830065965652466, fcurve.evaluate(9))
self.assertAlmostEqual(1.0, fcurve.evaluate(10))
def test_fcurve_extreme_handles(self):
# See D8752.
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "fcurve-extreme-handles.blend"))
cube = bpy.data.objects['Cube']
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))
self.assertAlmostEqual(0.022335870191454887, fcurve.evaluate(3))
self.assertAlmostEqual(0.06331237405538559, fcurve.evaluate(4))
self.assertAlmostEqual(0.16721539199352264, fcurve.evaluate(5))
self.assertAlmostEqual(0.8327845335006714, fcurve.evaluate(6))
self.assertAlmostEqual(0.9366875886917114, fcurve.evaluate(7))
self.assertAlmostEqual(0.9776642322540283, fcurve.evaluate(8))
self.assertAlmostEqual(0.9952865839004517, fcurve.evaluate(9))
self.assertAlmostEqual(1.0, fcurve.evaluate(10))
class PropertyInterpolationTest(AbstractAnimationTest, unittest.TestCase):
"""Test F-Curve interpolation on RNA properties.
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).
"""
def setUp(self):
bpy.ops.wm.read_homefile(use_factory_startup=True)
def test_float(self) -> None:
scene: bpy.types.Scene = bpy.context.scene
camera_ob: bpy.types.Object = scene.objects["Camera"]
camera: bpy.types.Camera = camera_ob.data
camera.lens = 16
camera.keyframe_insert('lens', frame=0)
camera.lens = 32
camera.keyframe_insert('lens', frame=64)
self._make_all_keys_linear()
fcurve = _first_fcurve(camera)
scene.frame_set(0)
self.assertAlmostEqual(16, camera.lens)
self.assertAlmostEqual(16, fcurve.evaluate(0))
scene.frame_set(1)
self.assertAlmostEqual(16.25, camera.lens)
self.assertAlmostEqual(16.25, fcurve.evaluate(1))
scene.frame_set(2)
self.assertAlmostEqual(16.5, camera.lens)
self.assertAlmostEqual(16.5, fcurve.evaluate(2))
scene.frame_set(11)
self.assertAlmostEqual(18.75, camera.lens)
self.assertAlmostEqual(18.75, fcurve.evaluate(11))
scene.frame_set(64)
self.assertAlmostEqual(32, camera.lens)
self.assertAlmostEqual(32, fcurve.evaluate(64))
def test_int(self) -> None:
scene: bpy.types.Scene = bpy.context.scene
render: bpy.types.RenderSettings = scene.render
render.simplify_subdivision = 16
render.keyframe_insert('simplify_subdivision', frame=0)
render.simplify_subdivision = 32
render.keyframe_insert('simplify_subdivision', frame=64)
self._make_all_keys_linear()
fcurve = _first_fcurve(scene)
scene.frame_set(0)
self.assertAlmostEqual(16, render.simplify_subdivision)
self.assertAlmostEqual(16, fcurve.evaluate(0))
scene.frame_set(1) # 16.25 rounds down.
self.assertAlmostEqual(16, render.simplify_subdivision)
self.assertAlmostEqual(16, fcurve.evaluate(1))
scene.frame_set(2) # 16.50 rounds up.
self.assertAlmostEqual(17, render.simplify_subdivision)
self.assertAlmostEqual(17, fcurve.evaluate(2))
scene.frame_set(11) # 18.75 rounds up.
self.assertAlmostEqual(19, render.simplify_subdivision)
self.assertAlmostEqual(19, fcurve.evaluate(11))
scene.frame_set(64)
self.assertAlmostEqual(32, render.simplify_subdivision)
self.assertAlmostEqual(32, fcurve.evaluate(64))
def test_bool(self) -> None:
scene: bpy.types.Scene = bpy.context.scene
render: bpy.types.RenderSettings = scene.render
render.use_simplify = False
render.keyframe_insert('use_simplify', frame=0)
render.use_simplify = True
render.keyframe_insert('use_simplify', frame=64)
self._make_all_keys_linear()
fcurve = _first_fcurve(scene)
scene.frame_set(0)
self.assertEqual(False, render.use_simplify)
self.assertAlmostEqual(0, fcurve.evaluate(0))
scene.frame_set(32) # Boolean F-Curves should not interpolate at all.
self.assertEqual(False, render.use_simplify)
self.assertAlmostEqual(0, fcurve.evaluate(63))
scene.frame_set(63) # Should remain False until the frame it goes to True.
self.assertEqual(False, render.use_simplify)
self.assertAlmostEqual(0, fcurve.evaluate(63))
scene.frame_set(64)
self.assertEqual(True, render.use_simplify)
self.assertAlmostEqual(1, fcurve.evaluate(64))
def test_enum(self) -> None:
scene: bpy.types.Scene = bpy.context.scene
cube: bpy.types.Object = scene.objects["Cube"]
cube.rotation_mode = 'QUATERNION' # First item in the enum.
cube.keyframe_insert('rotation_mode', frame=0)
cube.rotation_mode = 'ZYX' # Item in the enum with the highest value.
# Yes, 'AXIS_ANGLE' is the last one in the enum, but that has value -1
# for historical reasons, and so for this test it's a bit weird.
cube.keyframe_insert('rotation_mode', frame=64)
self._make_all_keys_linear()
fcurve = _first_fcurve(cube)
scene.frame_set(0)
self.assertEqual('QUATERNION', cube.rotation_mode)
self.assertAlmostEqual(0, fcurve.evaluate(0))
scene.frame_set(32) # Enum F-Curves should not interpolate at all.
self.assertEqual('QUATERNION', cube.rotation_mode)
scene.frame_set(63) # Should remain 'QUATERNION' until the frame it goes to another value.
self.assertEqual('QUATERNION', cube.rotation_mode)
self.assertAlmostEqual(0, fcurve.evaluate(63))
scene.frame_set(64)
self.assertEqual('ZYX', cube.rotation_mode)
self.assertAlmostEqual(6, fcurve.evaluate(64))
def _make_all_keys_linear(self) -> None:
"""Make all keys in all Actions linearly interpolated.
This makes the code in this test a bit simpler, and shouldn't have any
effect on the actual mapping of the F-Curve value to the property value.
"""
for action in bpy.data.actions:
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):
def setUp(self):
super().setUp()
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "euler-filter.blend"))
def test_multi_channel_filter(self):
"""Test fixing discontinuities that require all X/Y/Z rotations to work."""
self.activate_object('Three-Channel-Jump')
fcu_rot = self.active_object_rotation_channels()
2022-04-19 15:25:55 +10:00
# # Check some pre-filter values to make sure the file is as we expect.
# Keyframes before the "jump". These shouldn't be touched by the filter.
self.assertEqualAngle(-87.5742, fcu_rot[0], 22)
self.assertEqualAngle(69.1701, fcu_rot[1], 22)
self.assertEqualAngle(-92.3918, fcu_rot[2], 22)
# Keyframes after the "jump". These should be updated by the filter.
self.assertEqualAngle(81.3266, fcu_rot[0], 23)
self.assertEqualAngle(111.422, fcu_rot[1], 23)
self.assertEqualAngle(76.5996, fcu_rot[2], 23)
with bpy.context.temp_override(**self.get_context()):
bpy.ops.graph.euler_filter()
# Keyframes before the "jump". These shouldn't be touched by the filter.
self.assertEqualAngle(-87.5742, fcu_rot[0], 22)
self.assertEqualAngle(69.1701, fcu_rot[1], 22)
self.assertEqualAngle(-92.3918, fcu_rot[2], 22)
# Keyframes after the "jump". These should be updated by the filter.
self.assertEqualAngle(-98.6734, fcu_rot[0], 23)
self.assertEqualAngle(68.5783, fcu_rot[1], 23)
self.assertEqualAngle(-103.4, fcu_rot[2], 23)
def test_single_channel_filter(self):
"""Test fixing discontinuities in single channels."""
self.activate_object('One-Channel-Jumps')
fcu_rot = self.active_object_rotation_channels()
2022-04-19 15:25:55 +10:00
# # Check some pre-filter values to make sure the file is as we expect.
# Keyframes before the "jump". These shouldn't be touched by the filter.
self.assertEqualAngle(360, fcu_rot[0], 15)
self.assertEqualAngle(396, fcu_rot[1], 21) # X and Y are keyed on different frames.
# Keyframes after the "jump". These should be updated by the filter.
self.assertEqualAngle(720, fcu_rot[0], 16)
self.assertEqualAngle(72, fcu_rot[1], 22)
with bpy.context.temp_override(**self.get_context()):
bpy.ops.graph.euler_filter()
# Keyframes before the "jump". These shouldn't be touched by the filter.
self.assertEqualAngle(360, fcu_rot[0], 15)
self.assertEqualAngle(396, fcu_rot[1], 21) # X and Y are keyed on different frames.
# Keyframes after the "jump". These should be updated by the filter.
self.assertEqualAngle(360, fcu_rot[0], 16)
self.assertEqualAngle(432, fcu_rot[1], 22)
def assertEqualAngle(self, angle_degrees: float, fcurve: bpy.types.FCurve, frame: int) -> None:
self.assertAlmostEqual(
radians(angle_degrees),
fcurve.evaluate(frame),
4,
"Expected %.3f degrees, but FCurve %s[%d] evaluated to %.3f on frame %d" % (
angle_degrees, fcurve.data_path, fcurve.array_index, degrees(fcurve.evaluate(frame)), frame,
)
)
@staticmethod
def get_context():
ctx = bpy.context.copy()
for area in bpy.context.window.screen.areas:
if area.type != 'GRAPH_EDITOR':
continue
ctx['area'] = area
ctx['space'] = area.spaces.active
break
return ctx
@staticmethod
def activate_object(object_name: str) -> None:
ob = bpy.data.objects[object_name]
bpy.context.view_layer.objects.active = ob
@staticmethod
def active_object_rotation_channels() -> list[bpy.types.FCurve]:
ob = bpy.context.view_layer.objects.active
channelbag = _channelbag(ob)
return [channelbag.fcurves.find('rotation_euler', index=idx) for idx in range(3)]
2023-09-15 10:44:40 +10:00
def get_view3d_context():
ctx = bpy.context.copy()
for area in bpy.context.window.screen.areas:
if area.type != 'VIEW_3D':
continue
ctx['area'] = area
ctx['space'] = area.spaces.active
break
return ctx
class KeyframeInsertTest(AbstractAnimationTest, unittest.TestCase):
def setUp(self):
super().setUp()
bpy.ops.wm.read_homefile(use_factory_startup=True)
def test_keyframe_insertion_basic(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 100
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
bpy.context.scene.frame_set(frame)
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 = fcurve.keyframe_points[key_index]
self.assertEqual(key.co.x, key_index)
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
def test_keyframe_insert_keytype(self):
key_object = bpy.context.active_object
# Inserting a key with a specific type should work.
key_object.keyframe_insert("location", keytype='GENERATED')
# Unsupported/unknown types should be rejected.
with self.assertRaises(ValueError):
key_object.keyframe_insert("rotation_euler", keytype='UNSUPPORTED')
# Only a single key should have been inserted.
keys = _first_fcurve(key_object).keyframe_points
self.assertEqual(len(keys), 1)
self.assertEqual(keys[0].type, 'GENERATED')
def test_keyframe_insertion_high_frame_number(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 100
frame_offset = 1000000
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
bpy.context.scene.frame_set(frame + frame_offset)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
for key_index in range(key_count):
key = _first_fcurve(key_object).keyframe_points[key_index]
2023-09-15 10:44:40 +10:00
self.assertEqual(key.co.x, key_index + frame_offset)
bpy.ops.object.delete(use_global=False)
def test_keyframe_insertion_subframes_basic(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 50
with bpy.context.temp_override(**get_view3d_context()):
for i in range(key_count):
2023-09-15 10:44:40 +10:00
bpy.context.scene.frame_set(0, subframe=i / key_count)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
for key_index in range(key_count):
key = _first_fcurve(key_object).keyframe_points[key_index]
2023-09-15 10:44:40 +10:00
self.assertAlmostEqual(key.co.x, key_index / key_count)
bpy.ops.object.delete(use_global=False)
def test_keyframe_insertion_subframes_high_frame_number(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 50
frame_offset = 1000000
with bpy.context.temp_override(**get_view3d_context()):
for i in range(key_count):
2023-09-15 10:44:40 +10:00
bpy.context.scene.frame_set(frame_offset, subframe=i / key_count)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
# These are the possible floating point steps from "1.000.000" up to "1.000.001".
floating_point_steps = [
1000000.0,
1000000.0625,
1000000.125,
1000000.1875,
1000000.25,
1000000.3125,
1000000.375,
1000000.4375,
1000000.5,
1000000.5625,
1000000.625,
1000000.6875,
1000000.75,
1000000.8125,
1000000.875,
1000000.9375,
2023-09-15 10:44:40 +10:00
# Even though range() is exclusive, the floating point limitations mean keys end up on that position.
1000001.0
]
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)
# This checks that there is a key on every possible floating point value and not more than that.
self.assertEqual(len(floating_point_steps), len(keyframe_points))
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
2023-09-15 10:44:40 +10:00
class KeyframeDeleteTest(AbstractAnimationTest, unittest.TestCase):
def setUp(self):
super().setUp()
bpy.ops.wm.read_homefile(use_factory_startup=True)
def test_keyframe_deletion_basic(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 100
with bpy.context.temp_override(**get_view3d_context()):
bpy.context.scene.frame_set(-1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
fcu = _first_fcurve(key_object)
for i in range(key_count):
fcu.keyframe_points.insert(frame=i, value=0)
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
bpy.context.scene.frame_set(frame)
bpy.ops.anim.keyframe_delete_by_name(type="Location")
# Only the key on frame -1 should be left
self.assertEqual(len(fcu.keyframe_points), 1)
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
def test_keyframe_deletion_high_frame_number(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 100
frame_offset = 1000000
with bpy.context.temp_override(**get_view3d_context()):
bpy.context.scene.frame_set(-1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
fcu = _first_fcurve(key_object)
for i in range(key_count):
2023-09-15 10:44:40 +10:00
fcu.keyframe_points.insert(frame=i + frame_offset, value=0)
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
2023-09-15 10:44:40 +10:00
bpy.context.scene.frame_set(frame + frame_offset)
bpy.ops.anim.keyframe_delete_by_name(type="Location")
# Only the key on frame -1 should be left
self.assertEqual(len(fcu.keyframe_points), 1)
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
def test_keyframe_deletion_subframe_basic(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 50
with bpy.context.temp_override(**get_view3d_context()):
bpy.context.scene.frame_set(-1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
fcu = _first_fcurve(key_object)
for i in range(key_count):
2023-09-15 10:44:40 +10:00
fcu.keyframe_points.insert(frame=i / key_count, value=0)
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
2023-09-15 10:44:40 +10:00
bpy.context.scene.frame_set(0, subframe=frame / key_count)
bpy.ops.anim.keyframe_delete_by_name(type="Location")
# Only the key on frame -1 should be left
self.assertEqual(len(fcu.keyframe_points), 1)
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
def test_keyframe_deletion_subframe_high_frame_number(self):
bpy.ops.mesh.primitive_monkey_add()
key_count = 50
frame_offset = 1000000
with bpy.context.temp_override(**get_view3d_context()):
bpy.context.scene.frame_set(-1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
key_object = bpy.context.active_object
fcu = _first_fcurve(key_object)
for i in range(key_count):
2023-09-15 10:44:40 +10:00
fcu.keyframe_points.insert(frame=i / key_count + frame_offset, value=0)
with bpy.context.temp_override(**get_view3d_context()):
for frame in range(key_count):
2023-09-15 10:44:40 +10:00
bpy.context.scene.frame_set(frame_offset, subframe=frame / key_count)
bpy.ops.anim.keyframe_delete_by_name(type="Location")
# Only the key on frame -1 should be left
2023-09-15 10:44:40 +10:00
# This works even though there are floating point precision issues,
# because the deletion has the exact same precision as the insertion.
2023-09-15 10:44:40 +10:00
# Due to that, the code calls keyframe_delete_by_name for
# every floating point step multiple times.
self.assertEqual(len(fcu.keyframe_points), 1)
2023-09-15 10:44:40 +10:00
bpy.ops.object.delete(use_global=False)
def main():
global args
import argparse
if '--' in sys.argv:
argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
else:
argv = sys.argv
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()