Files
test/tests/python/bl_animation_keyframing.py
Christoph Lendenfeld 03a66af318 Anim: Unit tests for "Insert Needed"
Add unit tests for the user preference option "Insert Needed"

Basic tests for objects and bones that check if autokeying in
combination with "Insert Needed" only
* keys all location channels on the first key
* keys only the modified channel on the second key

It is supposed to add only keyframes that have been affected
by the used transform operation.
E.g. translating an object will only add keys on translation keys.

The behavior of keying all property array channels first, and then
only add keys on values that have actually changed may change
in the future. Ideally it would only key actual changes to begin
with. But there is no way to do this right now.

Pull Request: https://projects.blender.org/blender/blender/pulls/116419
2024-01-05 09:52:22 +01:00

485 lines
20 KiB
Python

# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import unittest
import bpy
import pathlib
import sys
from math import radians
"""
blender -b -noaudio --factory-startup --python tests/python/bl_animation_keyframing.py -- --testdir /path/to/lib/tests/animation
"""
def _fcurve_paths_match(fcurves: list, expected_paths: list) -> bool:
data_paths = list(set([fcurve.data_path for fcurve in fcurves]))
return sorted(data_paths) == sorted(expected_paths)
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
def _create_animation_object():
anim_object = bpy.data.objects.new("anim_object", None)
# Ensure that the rotation mode is correct so we can check against rotation_euler
anim_object.rotation_mode = "XYZ"
bpy.context.scene.collection.objects.link(anim_object)
bpy.context.view_layer.objects.active = anim_object
anim_object.select_set(True)
return anim_object
_BONE_NAME = "bone"
def _create_armature():
armature = bpy.data.armatures.new("anim_armature")
armature_obj = bpy.data.objects.new("anim_object", armature)
bpy.context.scene.collection.objects.link(armature_obj)
bpy.context.view_layer.objects.active = armature_obj
armature_obj.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
edit_bone = armature.edit_bones.new(_BONE_NAME)
edit_bone.head = (1, 0, 0)
bpy.ops.object.mode_set(mode='POSE')
armature_obj.pose.bones[_BONE_NAME].rotation_mode = "XYZ"
armature_obj.pose.bones[_BONE_NAME].bone.select = True
armature_obj.pose.bones[_BONE_NAME].bone.select_head = True
armature_obj.pose.bones[_BONE_NAME].bone.select_tail = True
bpy.ops.object.mode_set(mode='OBJECT')
return armature_obj
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)
match = _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
return match
def _insert_from_user_preference_test(enabled_user_pref_fields: set, expected_paths: list):
keyed_object = _create_animation_object()
bpy.context.preferences.edit.key_insert_channels = enabled_user_pref_fields
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.anim.keyframe_insert()
match = _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
return match
def _get_keying_set(scene, name: str):
return scene.keying_sets_all[scene.keying_sets_all.find(name)]
def _insert_with_keying_set_test(keying_set_name: str, expected_paths: list):
scene = bpy.context.scene
keying_set = _get_keying_set(scene, keying_set_name)
scene.keying_sets.active = keying_set
keyed_object = _create_animation_object()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.anim.keyframe_insert()
match = _fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
return match
class AbstractKeyframingTest:
def setUp(self):
super().setUp()
bpy.ops.wm.read_homefile(use_factory_startup=True)
class InsertKeyTest(AbstractKeyframingTest, unittest.TestCase):
""" Ensure keying things by name or with a keying set adds the right keys. """
def test_insert_by_name(self):
self.assertTrue(_insert_by_name_test("Location", ["location"]))
self.assertTrue(_insert_by_name_test("Rotation", ["rotation_euler"]))
self.assertTrue(_insert_by_name_test("Scaling", ["scale"]))
self.assertTrue(_insert_by_name_test("LocRotScale", ["location", "rotation_euler", "scale"]))
def test_insert_with_keying_set(self):
self.assertTrue(_insert_with_keying_set_test("Location", ["location"]))
self.assertTrue(_insert_with_keying_set_test("Rotation", ["rotation_euler"]))
self.assertTrue(_insert_with_keying_set_test("Scale", ["scale"]))
self.assertTrue(
_insert_with_keying_set_test("Location, Rotation & Scale", ["location", "rotation_euler", "scale"])
)
def test_insert_from_user_preferences(self):
self.assertTrue(_insert_from_user_preference_test({"LOCATION"}, ["location"]))
self.assertTrue(_insert_from_user_preference_test({"ROTATION"}, ["rotation_euler"]))
self.assertTrue(_insert_from_user_preference_test({"SCALE"}, ["scale"]))
self.assertTrue(_insert_from_user_preference_test(
{"LOCATION", "ROTATION", "SCALE"}, ["location", "rotation_euler", "scale"]))
class VisualKeyingTest(AbstractKeyframingTest, unittest.TestCase):
""" Check if visual keying produces the correct keyframe values. """
def test_visual_location_keying_set(self):
t_value = 1
target = _create_animation_object()
target.location = (t_value, t_value, t_value)
constrained = _create_animation_object()
constraint = constrained.constraints.new("COPY_LOCATION")
constraint.target = target
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:
self.assertEqual(fcurve.keyframe_points[0].co.y, t_value)
bpy.data.objects.remove(target, do_unlink=True)
bpy.data.objects.remove(constrained, do_unlink=True)
def test_visual_rotation_keying_set(self):
rot_value_deg = 45
rot_value_rads = radians(rot_value_deg)
target = _create_animation_object()
target.rotation_euler = (rot_value_rads, rot_value_rads, rot_value_rads)
constrained = _create_animation_object()
constraint = constrained.constraints.new("COPY_ROTATION")
constraint.target = target
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:
self.assertAlmostEqual(fcurve.keyframe_points[0].co.y, rot_value_rads, places=4)
bpy.data.objects.remove(target, do_unlink=True)
bpy.data.objects.remove(constrained, do_unlink=True)
def test_visual_location_user_pref_override(self):
# When enabling the user preference setting,
# the normal keying sets behave like their visual keying set counterpart.
bpy.context.preferences.edit.use_visual_keying = True
t_value = 1
target = _create_animation_object()
target.location = (t_value, t_value, t_value)
constrained = _create_animation_object()
constraint = constrained.constraints.new("COPY_LOCATION")
constraint.target = target
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:
self.assertEqual(fcurve.keyframe_points[0].co.y, t_value)
bpy.data.objects.remove(target, do_unlink=True)
bpy.data.objects.remove(constrained, do_unlink=True)
bpy.context.preferences.edit.use_visual_keying = False
def test_visual_location_user_pref(self):
target = _create_animation_object()
t_value = 1
target.location = (t_value, t_value, t_value)
constrained = _create_animation_object()
constraint = constrained.constraints.new("COPY_LOCATION")
constraint.target = target
bpy.context.preferences.edit.use_visual_keying = True
bpy.context.preferences.edit.key_insert_channels = {"LOCATION"}
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.anim.keyframe_insert()
for fcurve in constrained.animation_data.action.fcurves:
self.assertEqual(fcurve.keyframe_points[0].co.y, t_value)
bpy.data.objects.remove(target, do_unlink=True)
bpy.data.objects.remove(constrained, do_unlink=True)
bpy.context.preferences.edit.use_visual_keying = False
class CycleAwareKeyingTest(AbstractKeyframingTest, unittest.TestCase):
""" Check if cycle aware keying remaps the keyframes correctly and adds fcurve modifiers. """
def test_insert_location_cycle_aware(self):
# In order to make cycle aware keying work, the action needs to be created and have the
# frame_range set plus the use_frame_range flag set to True.
keyed_object = _create_animation_object()
bpy.context.scene.tool_settings.use_keyframe_cycle_aware = True
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.anim.keyframe_insert_by_name(type="Location")
action = keyed_object.animation_data.action
action.use_cyclic = True
action.use_frame_range = True
cyclic_range_end = 20
action.frame_range = [1, cyclic_range_end]
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(5)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
# Will be mapped to frame 3.
# This will insert the key based on the user preference settings.
bpy.context.preferences.edit.key_insert_channels = {"LOCATION"}
bpy.context.scene.frame_set(22)
bpy.ops.anim.keyframe_insert()
# Will be mapped to frame 9.
bpy.context.scene.frame_set(-10)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
# Check that only location keys have been created.
self.assertTrue(_fcurve_paths_match(action.fcurves, ["location"]))
expected_keys = [1, 3, 5, 9, 20]
for fcurve in action.fcurves:
self.assertEqual(len(fcurve.keyframe_points), len(expected_keys))
key_index = 0
for key in fcurve.keyframe_points:
self.assertEqual(key.co.x, expected_keys[key_index])
key_index += 1
# All fcurves should have a cycles modifier.
self.assertTrue(fcurve.modifiers[0].type == "CYCLES")
bpy.data.objects.remove(keyed_object, do_unlink=True)
class AutoKeyframingTest(AbstractKeyframingTest, unittest.TestCase):
def test_autokey_basic(self):
keyed_object = _create_animation_object()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_available = False
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.transform.translate(value=(1, 0, 0))
action = keyed_object.animation_data.action
self.assertTrue(_fcurve_paths_match(action.fcurves, ["location", "rotation_euler", "scale"]))
bpy.data.objects.remove(keyed_object, do_unlink=True)
def test_autokey_bone(self):
armature_obj = _create_armature()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_available = False
bpy.ops.object.mode_set(mode='POSE')
# Not overriding the context because it would mean context.selected_pose_bones is empty
# resulting in a failure to move/key the bone
bpy.ops.transform.translate(value=(1, 0, 0))
bpy.ops.object.mode_set(mode='OBJECT')
action = armature_obj.animation_data.action
bone_path = f"pose.bones[\"{_BONE_NAME}\"]"
expected_paths = [f"{bone_path}.location", f"{bone_path}.rotation_euler", f"{bone_path}.scale"]
self.assertTrue(_fcurve_paths_match(action.fcurves, expected_paths))
bpy.data.objects.remove(armature_obj, do_unlink=True)
class InsertAvailableTest(AbstractKeyframingTest, unittest.TestCase):
def test_autokey_available_object(self):
keyed_object = _create_animation_object()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_available = True
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.anim.keyframe_insert_by_name(type="Rotation")
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0))
# Test that no new keyframes have been added.
action = keyed_object.animation_data.action
self.assertTrue(_fcurve_paths_match(action.fcurves, ["rotation_euler"]))
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0))
action = keyed_object.animation_data.action
self.assertTrue(_fcurve_paths_match(action.fcurves, ["location", "rotation_euler"]))
for fcurve in action.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)
else:
raise AssertionError(f"Did not expect keys other than location and rotation, got {fcurve.data_path}.")
bpy.context.preferences.edit.use_keyframe_insert_available = False
bpy.data.objects.remove(keyed_object, do_unlink=True)
def test_autokey_available_bone(self):
armature_obj = _create_armature()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_available = True
bpy.ops.object.mode_set(mode='POSE')
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.anim.keyframe_insert_by_name(type="Rotation")
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0))
# Test that no new keyframes have been added.
action = armature_obj.animation_data.action
bone_path = f"pose.bones[\"{_BONE_NAME}\"]"
expected_paths = [f"{bone_path}.rotation_euler"]
self.assertTrue(_fcurve_paths_match(action.fcurves, expected_paths))
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0))
expected_paths = [f"{bone_path}.location", f"{bone_path}.rotation_euler"]
self.assertTrue(_fcurve_paths_match(action.fcurves, expected_paths))
for fcurve in action.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)
else:
raise AssertionError(f"Did not expect keys other than location and rotation, got {fcurve.data_path}.")
bpy.data.objects.remove(armature_obj, do_unlink=True)
def test_insert_available_keying_set(self):
keyed_object = _create_animation_object()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_available = False
with bpy.context.temp_override(**_get_view3d_context()):
self.assertRaises(RuntimeError, bpy.ops.anim.keyframe_insert_by_name, type="Available")
self.assertIsNone(keyed_object.animation_data)
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.anim.keyframe_insert_by_name(type="Location")
bpy.context.scene.frame_set(5)
bpy.ops.anim.keyframe_insert_by_name(type="Available")
action = keyed_object.animation_data.action
self.assertTrue(_fcurve_paths_match(action.fcurves, ["location"]))
for fcurve in action.fcurves:
self.assertEqual(len(fcurve.keyframe_points), 2)
bpy.context.preferences.edit.use_keyframe_insert_available = False
bpy.data.objects.remove(keyed_object, do_unlink=True)
class InsertNeededTest(AbstractKeyframingTest, unittest.TestCase):
def setUp(self):
super().setUp()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.preferences.edit.use_keyframe_insert_needed = True
bpy.context.preferences.edit.use_keyframe_insert_available = False
def tearDown(self):
bpy.context.scene.tool_settings.use_keyframe_insert_auto = False
bpy.context.preferences.edit.use_keyframe_insert_needed = False
def test_insert_needed_object(self):
keyed_object = _create_animation_object()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.transform.translate(value=(-1, 0, 0))
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0))
action = keyed_object.animation_data.action
self.assertTrue(_fcurve_paths_match(action.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
# changed.
expected_keys = {
"location": (2, 1, 1)
}
self.assertEqual(len(action.fcurves), 3)
for fcurve in action.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))
def test_insert_needed_bone(self):
armature_obj = _create_armature()
bpy.ops.object.mode_set(mode='POSE')
with bpy.context.temp_override(**_get_view3d_context()):
bpy.context.scene.frame_set(1)
bpy.ops.transform.translate(value=(-1, 0, 0), orient_type='LOCAL')
bpy.context.scene.frame_set(5)
bpy.ops.transform.translate(value=(1, 0, 0), orient_type='LOCAL')
bpy.ops.object.mode_set(mode='OBJECT')
action = armature_obj.animation_data.action
bone_path = f"pose.bones[\"{_BONE_NAME}\"]"
self.assertTrue(_fcurve_paths_match(action.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
# changed.
expected_keys = {
f"{bone_path}.location": (2, 1, 1)
}
self.assertEqual(len(action.fcurves), 3)
for fcurve in action.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))
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()