tornavis/tests/python/bl_animation_keyframing.py

474 lines
19 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]))
data_paths.sort()
expected_paths.sort()
if data_paths != expected_paths:
raise AssertionError(
f"Expected paths do not match F-Curve paths. Expected: {expected_paths}. F-Curve: {data_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)
_fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
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()
_fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
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()
_fcurve_paths_match(keyed_object.animation_data.action.fcurves, expected_paths)
bpy.data.objects.remove(keyed_object, do_unlink=True)
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):
_insert_by_name_test("Location", ["location"])
_insert_by_name_test("Rotation", ["rotation_euler"])
_insert_by_name_test("Scaling", ["scale"])
_insert_by_name_test("LocRotScale", ["location", "rotation_euler", "scale"])
def test_insert_with_keying_set(self):
_insert_with_keying_set_test("Location", ["location"])
_insert_with_keying_set_test("Rotation", ["rotation_euler"])
_insert_with_keying_set_test("Scale", ["scale"])
_insert_with_keying_set_test("Location, Rotation & Scale", ["location", "rotation_euler", "scale"])
def test_insert_from_user_preferences(self):
_insert_from_user_preference_test({"LOCATION"}, ["location"])
_insert_from_user_preference_test({"ROTATION"}, ["rotation_euler"])
_insert_from_user_preference_test({"SCALE"}, ["scale"])
_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 tearDown(self):
super().tearDown()
bpy.context.preferences.edit.use_visual_keying = False
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)
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)
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)
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)
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.
_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")
class AutoKeyframingTest(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_available = False
bpy.context.preferences.edit.use_keyframe_insert_needed = False
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = False
def tearDown(self):
super().tearDown()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = False
def test_autokey_basic(self):
keyed_object = _create_animation_object()
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"])
def test_autokey_bone(self):
armature_obj = _create_armature()
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"]
_fcurve_paths_match(action.fcurves, expected_paths)
class InsertAvailableTest(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_available = True
bpy.context.preferences.edit.use_keyframe_insert_needed = False
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = False
def tearDown(self):
super().tearDown()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = False
bpy.context.preferences.edit.use_keyframe_insert_available = False
def test_autokey_available_object(self):
keyed_object = _create_animation_object()
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
_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
_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}.")
def test_autokey_available_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.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"]
_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"]
_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}.")
def test_insert_available_keying_set(self):
keyed_object = _create_animation_object()
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
_fcurve_paths_match(action.fcurves, ["location"])
for fcurve in action.fcurves:
self.assertEqual(len(fcurve.keyframe_points), 2)
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_auto_keyframe_insert_needed = True
bpy.context.preferences.edit.use_keyframe_insert_available = False
def tearDown(self):
super().tearDown()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = False
bpy.context.preferences.edit.use_keyframe_insert_needed = False
bpy.context.preferences.edit.use_auto_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
_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}\"]"
_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()