mirror of
https://projects.blender.org/blender/blender.git
synced 2025-01-22 07:22:12 -05:00
2d1fc1bcdd
The issue is that certain RNA paths cannot be generated to come from an ID and `RNA_path_from_ID_to_property` will return no value. We are using that function in the keyframing code to allow passing in a pointer to a bone and a path relative to that bone. Since there is currently no good way to find the path from the ID to an arbitrary struct pointer (see #122427), this patch is a workaround that uses the struct_pointer IF that happens to be an ID pointer. Of course that still has the core limitation in place but until a better solution is available on the RNA side this is the best we can do. Pull Request: https://projects.blender.org/blender/blender/pulls/132552
855 lines
35 KiB
Python
855 lines
35 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 --factory-startup --python tests/python/bl_animation_keyframing.py -- --testdir /path/to/tests/data/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 _get_nla_context():
|
|
ctx = bpy.context.copy()
|
|
|
|
for area in bpy.context.window.screen.areas:
|
|
if area.type != 'NLA_EDITOR':
|
|
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"])
|
|
|
|
def test_keying_creates_default_groups(self):
|
|
keyed_object = _create_animation_object()
|
|
|
|
bpy.context.preferences.edit.key_insert_channels = {'LOCATION'}
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
# Check the F-Curves paths.
|
|
expect_paths = ["location", "location", "location"]
|
|
actual_paths = [fcurve.data_path for fcurve in keyed_object.animation_data.action.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]
|
|
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]
|
|
self.assertEqual(actual_groups, expect_groups)
|
|
|
|
def test_insert_custom_properties(self):
|
|
# Used to create a datablock reference property.
|
|
ref_object = bpy.data.objects.new("ref_object", None)
|
|
bpy.context.scene.collection.objects.link(ref_object)
|
|
|
|
bpy.context.preferences.edit.key_insert_channels = {"CUSTOM_PROPS"}
|
|
keyed_object = _create_animation_object()
|
|
|
|
keyed_properties = {
|
|
"int": 1,
|
|
"float": 1.0,
|
|
"bool": True,
|
|
"int_array": [1, 2, 3],
|
|
"float_array": [1.0, 2.0, 3.0],
|
|
"bool_array": [True, False, True],
|
|
"'escaped'": 1,
|
|
'"escaped"': 1
|
|
}
|
|
|
|
unkeyed_properties = {
|
|
"str": "unkeyed",
|
|
"reference": ref_object,
|
|
}
|
|
|
|
for path, value in keyed_properties.items():
|
|
keyed_object[path] = value
|
|
|
|
for path, value in unkeyed_properties.items():
|
|
keyed_object[path] = value
|
|
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
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)
|
|
bpy.data.objects.remove(keyed_object, do_unlink=True)
|
|
|
|
def test_key_selection_state(self):
|
|
keyed_object = _create_animation_object()
|
|
bpy.context.preferences.edit.key_insert_channels = {"LOCATION"}
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.ops.anim.keyframe_insert()
|
|
bpy.context.scene.frame_set(5)
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
for fcurve in keyed_object.animation_data.action.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)
|
|
|
|
def test_keyframe_insert_py_func(self):
|
|
curve_object = _create_animation_object()
|
|
|
|
# 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
|
|
|
|
self.assertEqual(len(ob_fcurves), 3,
|
|
"Keying 'location' without any array index should have created 3 F-Curves")
|
|
self.assertEqual(3 * ['location'], [fcurve.data_path for fcurve in ob_fcurves])
|
|
self.assertEqual([0, 1, 2], [fcurve.array_index for fcurve in ob_fcurves])
|
|
|
|
ob_fcurves.clear()
|
|
|
|
# Test on 'rotation_quaterion' (4 items), with explicit index=-1.
|
|
self.assertTrue(curve_object.keyframe_insert('rotation_quaternion', index=-1))
|
|
self.assertEqual(len(ob_fcurves), 4,
|
|
"Keying 'rotation_quaternion' with index=-1 should have created 4 F-Curves")
|
|
self.assertEqual(4 * ['rotation_quaternion'], [fcurve.data_path for fcurve in ob_fcurves])
|
|
self.assertEqual([0, 1, 2, 3], [fcurve.array_index for fcurve in ob_fcurves])
|
|
|
|
ob_fcurves.clear()
|
|
|
|
# Test on 'scale' (3 items) with explicit index=1.
|
|
self.assertTrue(curve_object.keyframe_insert('scale', index=2))
|
|
self.assertEqual(len(ob_fcurves), 1,
|
|
"Keying 'scale' with index=2 should have created 1 F-Curve")
|
|
self.assertEqual('scale', ob_fcurves[0].data_path)
|
|
self.assertEqual(2, ob_fcurves[0].array_index)
|
|
|
|
def test_keyframe_insert_py_func_with_group(self):
|
|
curve_object = _create_animation_object()
|
|
|
|
# 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
|
|
|
|
self.assertEqual(3 * ['location'], [fcurve.data_path for fcurve in fcurves])
|
|
self.assertEqual([0, 1, 2], [fcurve.array_index for fcurve in fcurves])
|
|
self.assertEqual(["Téšt"], [group.name for group in fgroups])
|
|
self.assertEqual(3 * ["Téšt"], [fcurve.group and fcurve.group.name for fcurve in fcurves])
|
|
|
|
fcurves.clear()
|
|
while fgroups:
|
|
fgroups.remove(fgroups[0])
|
|
|
|
# Test with property that does not have predefined group name.
|
|
self.assertTrue(curve_object.keyframe_insert('show_wire', group="Téšt"))
|
|
self.assertEqual('show_wire', fcurves[0].data_path)
|
|
self.assertEqual(["Téšt"], [group.name for group in fgroups])
|
|
|
|
def test_keyframe_insert_nested_rna_path(self):
|
|
bpy.ops.mesh.primitive_cube_add()
|
|
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
|
|
self.assertEqual(len(fcurves), 1)
|
|
self.assertEqual(fcurves[0].data_path, 'attributes["test"].data[0].value')
|
|
|
|
|
|
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 setUp(self):
|
|
super().setUp()
|
|
bpy.context.scene.tool_settings.use_keyframe_cycle_aware = True
|
|
|
|
# Deselect the default cube, because this test works on a specific
|
|
# object. Operators that work on all selected objects shouldn't work on
|
|
# anything else but that object.
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
|
|
def tearDown(self):
|
|
bpy.context.scene.tool_settings.use_keyframe_cycle_aware = False
|
|
super().tearDown()
|
|
|
|
def test_insert_by_name(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()
|
|
|
|
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
|
|
action.frame_range = [1, 20]
|
|
|
|
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.
|
|
bpy.context.preferences.edit.key_insert_channels = {"LOCATION"}
|
|
bpy.context.scene.frame_set(22)
|
|
bpy.ops.anim.keyframe_insert_by_name(type="Location")
|
|
|
|
# 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.0, 3.0, 5.0, 9.0, 20.0]
|
|
|
|
for fcurve in action.fcurves:
|
|
actual_keys = [key.co.x for key in fcurve.keyframe_points]
|
|
self.assertEqual(expected_keys, actual_keys)
|
|
|
|
# All fcurves should have a cycles modifier.
|
|
self.assertTrue(fcurve.modifiers[0].type == "CYCLES")
|
|
|
|
def test_insert_key(self):
|
|
keyed_object = _create_animation_object()
|
|
|
|
bpy.context.preferences.edit.key_insert_channels = {'LOCATION'}
|
|
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
action = keyed_object.animation_data.action
|
|
action.use_cyclic = True
|
|
action.use_frame_range = True
|
|
action.frame_range = [1, 20]
|
|
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.context.scene.frame_set(5)
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
# Will be mapped to frame 3.
|
|
bpy.context.scene.frame_set(22)
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
expected_keys = [1.0, 3.0, 5.0, 20.0]
|
|
|
|
for fcurve in action.fcurves:
|
|
actual_keys = [key.co.x for key in fcurve.keyframe_points]
|
|
self.assertEqual(expected_keys, actual_keys)
|
|
|
|
# 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)
|
|
|
|
def test_key_selection_state(self):
|
|
armature_obj = _create_armature()
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
bpy.ops.transform.translate(value=(1, 0, 0))
|
|
bpy.context.scene.frame_set(5)
|
|
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:
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
def test_insert_available(self):
|
|
keyed_object = _create_animation_object()
|
|
self.assertIsNone(keyed_object.animation_data, "Precondition check: test object should not have animdata yet")
|
|
|
|
keyed_ok = keyed_object.keyframe_insert("location", options={'INSERTKEY_AVAILABLE'})
|
|
self.assertFalse(keyed_ok, "Should not key with INSERTKEY_AVAILABLE when no F-Curves are available")
|
|
|
|
|
|
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 _create_nla_anim_object():
|
|
"""
|
|
Creates an object with 3 NLA tracks each with a strip that has its own action.
|
|
The middle layer is additive.
|
|
Creates a key on frame 0 and frame 10 for each of them.
|
|
The values are:
|
|
top: 0, 0
|
|
add: 0, 1
|
|
base: 0, 1
|
|
"""
|
|
anim_object = bpy.data.objects.new("anim_object", None)
|
|
bpy.context.scene.collection.objects.link(anim_object)
|
|
bpy.context.view_layer.objects.active = anim_object
|
|
anim_object.select_set(True)
|
|
anim_object.animation_data_create()
|
|
|
|
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.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)
|
|
assert action_base.is_action_layered
|
|
|
|
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.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)
|
|
strip.blend_type = "ADD"
|
|
assert action_add.is_action_layered
|
|
|
|
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.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)
|
|
assert action_top.is_action_layered
|
|
|
|
return anim_object
|
|
|
|
|
|
class NlaInsertTest(AbstractKeyframingTest, unittest.TestCase):
|
|
"""
|
|
Testing inserting keys into an NLA stack.
|
|
The system is expected to remap the inserted values based on the strips blend_type.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
bpy.context.preferences.edit.key_insert_channels = {'LOCATION'}
|
|
# Change one area to the NLA so we can call operators in it.
|
|
# Assumes there is at least one editor in the blender default startup file that is not the 3D viewport.
|
|
for area in bpy.context.window.screen.areas:
|
|
if area.type == 'VIEW_3D':
|
|
continue
|
|
area.type = "NLA_EDITOR"
|
|
break
|
|
|
|
# Deselect the default cube, because the NLA tests work on a specific
|
|
# object created for that test. Operators that work on all selected
|
|
# objects shouldn't work on anything else but that object.
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
|
|
def test_insert_failure(self):
|
|
# If the topmost track is set to "REPLACE" the system will fail
|
|
# when trying to insert keys into a layer beneath.
|
|
nla_anim_object = _create_nla_anim_object()
|
|
tracks = nla_anim_object.animation_data.nla_tracks
|
|
|
|
with bpy.context.temp_override(**_get_nla_context()):
|
|
bpy.ops.nla.select_all(action="DESELECT")
|
|
tracks.active = tracks["base"]
|
|
tracks["base"].strips[0].select = True
|
|
bpy.ops.nla.tweakmode_enter(use_upper_stack_evaluation=True)
|
|
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.context.scene.frame_set(5)
|
|
bpy.ops.anim.keyframe_insert()
|
|
|
|
base_action = bpy.data.actions["action_base"]
|
|
# 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)
|
|
# 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)
|
|
|
|
def test_insert_additive(self):
|
|
nla_anim_object = _create_nla_anim_object()
|
|
tracks = nla_anim_object.animation_data.nla_tracks
|
|
|
|
self.assertEqual(nla_anim_object, bpy.context.active_object)
|
|
self.assertEqual(None, nla_anim_object.animation_data.action)
|
|
|
|
# This leaves the additive track as the topmost track with influence
|
|
tracks["top"].mute = True
|
|
|
|
with bpy.context.temp_override(**_get_nla_context()):
|
|
bpy.ops.nla.select_all(action="DESELECT")
|
|
tracks.active = tracks["base"]
|
|
tracks["base"].strips[0].select = True
|
|
bpy.ops.nla.tweakmode_enter(use_upper_stack_evaluation=True)
|
|
|
|
base_action = bpy.data.actions["action_base"]
|
|
|
|
# Verify that tweak mode has switched to the correct Action.
|
|
self.assertEqual(base_action, nla_anim_object.animation_data.action)
|
|
|
|
# Inserting over the existing keyframe.
|
|
bpy.context.scene.frame_set(10)
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
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}
|
|
fcurves_expect = {
|
|
("location", 0),
|
|
("location", 1),
|
|
("location", 2),
|
|
}
|
|
self.assertEqual(fcurves_actual, fcurves_expect)
|
|
|
|
# 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)
|
|
|
|
# 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)
|
|
self.assertAlmostEqual(fcurve_loc_x.keyframe_points[-1].co[1], 1.0, 8)
|
|
|
|
|
|
class KeyframeDeleteTest(AbstractKeyframingTest, unittest.TestCase):
|
|
|
|
def test_delete_in_v3d_pose_mode(self):
|
|
armature = _create_armature()
|
|
bpy.context.scene.frame_set(1)
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
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)
|
|
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
with bpy.context.temp_override(**_get_view3d_context()):
|
|
bpy.ops.anim.keyframe_insert_by_name(type="Location")
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
|
|
|
|
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()
|