blender/tests/python/bl_animation_pose_slide.py
Christoph Lendenfeld d00c0d81e0 Anim: Pose Slide operators for objects
This implements the pose slide operators for objects.
It extends the `AnimTransformable` API to support objects,
which makes most of the pose slide code work right away.

The auto keying code had to be adjusted because it used
the "Whole Character" keying set which doesn't work for
objects.

Discussed in an animation & rigging module meeting [1]

Part of #156579

[1] https://devtalk.blender.org/t/2026-05-28-animation-rigging-module-meeting/45331#p-162762-ongoing-work-5

Pull Request: https://projects.blender.org/blender/blender/pulls/158906
2026-06-02 10:34:38 +02:00

367 lines
14 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy_extras import anim_utils
import unittest
import sys
import pathlib
"""
blender -b --factory-startup --python tests/python/bl_animation_pose_slide.py
"""
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
_BONE_NAME = "bone"
_CUSTOM_PROP = "test"
class AbstractPoseSlideTest(unittest.TestCase):
armature_ob: bpy.types.Object
pose_bone: bpy.types.PoseBone
def _keyframe_all(self):
self.pose_bone.keyframe_insert("location")
self.pose_bone.keyframe_insert("rotation_euler")
self.pose_bone.keyframe_insert("scale")
self.pose_bone.keyframe_insert("bbone_curveinx")
self.pose_bone.keyframe_insert(f'["{_CUSTOM_PROP}"]')
def setUp(self) -> None:
bpy.ops.wm.read_homefile(use_factory_startup=True)
armature = bpy.data.armatures.new("armature")
self.armature_ob = bpy.data.objects.new("armature_ob", armature)
bpy.context.scene.collection.objects.link(self.armature_ob)
bpy.context.view_layer.objects.active = self.armature_ob
bpy.ops.object.mode_set(mode='EDIT')
armature.edit_bones.new(_BONE_NAME)
bpy.ops.object.mode_set(mode='POSE')
self.pose_bone = self.armature_ob.pose.bones[_BONE_NAME]
self.pose_bone.rotation_mode = 'XYZ'
self.pose_bone[_CUSTOM_PROP] = 1.0
self.pose_bone.select = True
class BlendToDefaultPoseBone(AbstractPoseSlideTest):
def test_all_properties(self):
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.rotation_euler = (1, 1, 1)
self.pose_bone.scale = (1, 1, 1)
self.pose_bone.bbone_curveinx = 1
self.pose_bone[_CUSTOM_PROP] = 1.0
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self.pose_bone.rotation_euler = (2, 2, 2)
self.pose_bone.scale = (2, 2, 2)
self.pose_bone.bbone_curveinx = 2
self.pose_bone[_CUSTOM_PROP] = 2.0
self._keyframe_all()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.blend_with_rest(factor=1.0)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 0, 3)
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], 0, 3)
self.assertAlmostEqual(self.pose_bone.scale[i], 1, 3)
# Custom properties and bbone properties are not supported by this operator.
self.assertAlmostEqual(self.pose_bone.bbone_curveinx, 2, 3)
self.assertAlmostEqual(self.pose_bone[_CUSTOM_PROP], 2, 3)
def test_lock_x_axis(self):
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self._keyframe_all()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.blend_with_rest(factor=1.0, channels='LOC', axis_lock='X')
self.assertAlmostEqual(self.pose_bone.location[0], 0, 3)
self.assertAlmostEqual(self.pose_bone.location[1], 2, 3)
self.assertAlmostEqual(self.pose_bone.location[2], 2, 3)
class BlendToNeighborPoseBone(AbstractPoseSlideTest):
def test_all_properties(self):
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.rotation_euler = (1, 1, 1)
self.pose_bone.scale = (1, 1, 1)
self.pose_bone.bbone_curveinx = 1
self.pose_bone[_CUSTOM_PROP] = 1.0
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self.pose_bone.rotation_euler = (2, 2, 2)
self.pose_bone.scale = (2, 2, 2)
self.pose_bone.bbone_curveinx = 2
self.pose_bone[_CUSTOM_PROP] = 2.0
self._keyframe_all()
bpy.context.scene.frame_set(1)
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.blend_to_neighbor(factor=1.0, prev_frame=0, next_frame=10)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 2, 3)
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], 2, 3)
self.assertAlmostEqual(self.pose_bone.scale[i], 2, 3)
self.assertAlmostEqual(self.pose_bone.bbone_curveinx, 2, 3)
self.assertAlmostEqual(self.pose_bone[_CUSTOM_PROP], 2, 3)
class PushRelaxPoseBone(AbstractPoseSlideTest):
def _set_up_keys(self):
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.rotation_euler = (1, 1, 1)
self.pose_bone.scale = (1, 1, 1)
self.pose_bone.bbone_curveinx = 1
self.pose_bone[_CUSTOM_PROP] = 1.0
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self.pose_bone.rotation_euler = (2, 2, 2)
self.pose_bone.scale = (2, 2, 2)
self.pose_bone.bbone_curveinx = 2
self.pose_bone[_CUSTOM_PROP] = 2.0
self._keyframe_all()
bpy.context.scene.frame_set(5)
self.pose_bone.location = (5, 5, 5)
self.pose_bone.rotation_euler = (5, 5, 5)
self.pose_bone.scale = (5, 5, 5)
self.pose_bone.bbone_curveinx = 5
self.pose_bone[_CUSTOM_PROP] = 5.0
self._keyframe_all()
def test_relax_all_properties(self):
# Relax moves the pose to the linear breakdown between the given frames.
self._set_up_keys()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.relax(factor=1.0, prev_frame=0, next_frame=10)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.scale[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.bbone_curveinx, 1.5, 3)
self.assertAlmostEqual(self.pose_bone[_CUSTOM_PROP], 1.5, 3)
def test_push_all_properties(self):
# Push moves all properties away from the linear breakdown between the given frames.
# The distance moved at factor 1 depends on the inital distance from the breakdown pose.
self._set_up_keys()
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.push(factor=1.0, prev_frame=0, next_frame=10)
# Math to illustrate how the result is computed.
breakdown_value = 1 * 0.5 + 2 * 0.5
start_value = 5
expected_value = start_value + (start_value - breakdown_value)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], expected_value, 3)
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], expected_value, 3)
self.assertAlmostEqual(self.pose_bone.scale[i], expected_value, 3)
self.assertAlmostEqual(self.pose_bone.bbone_curveinx, expected_value, 3)
self.assertAlmostEqual(self.pose_bone[_CUSTOM_PROP], expected_value, 3)
class BreakdownerTestPoseBone(AbstractPoseSlideTest):
def setUp(self) -> None:
super().setUp()
bpy.context.preferences.edit.use_keyframe_insert_available = False
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = False
def tearDown(self) -> None:
super().tearDown()
bpy.context.preferences.edit.use_keyframe_insert_available = True
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = True
def test_no_keys(self):
# The case of no keys will produce no interpolation.
self.pose_bone.location = (1, 1, 1)
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.breakdown(factor=0.5)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1, 3)
def test_single_key(self):
# Creating a breakdown with a single key will not change the current value
# but adds a key if autokeying is enabled.
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.keyframe_insert("location")
bpy.context.scene.frame_set(10)
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.breakdown(factor=0.5)
action = self.armature_ob.animation_data.action
slot = self.armature_ob.animation_data.action_slot
channelbag = anim_utils.action_get_channelbag_for_slot(action, slot)
self.assertEqual(len(channelbag.fcurves), 3)
self.assertEqual(len(channelbag.fcurves[0].keyframe_points), 1)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1, 3)
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
with bpy.context.temp_override(**_get_view3d_context()):
bpy.ops.pose.breakdown(factor=0.5)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1, 3)
# The key count depends on the setting "Only insert available" in the user preferences.
# Also keys are only inserted into channels that already have keys.
self.assertEqual(len(channelbag.fcurves), 3)
# This depends on the setting "Only insert needed".
self.assertEqual(len(channelbag.fcurves[0].keyframe_points), 2)
def test_all_properties(self):
# By default the pose slide operators act on location, rotation, scale, bbone properties and custom properties.
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.rotation_euler = (1, 1, 1)
self.pose_bone.scale = (1, 1, 1)
self.pose_bone.bbone_curveinx = 1
self.pose_bone[_CUSTOM_PROP] = 1.0
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self.pose_bone.rotation_euler = (2, 2, 2)
self.pose_bone.scale = (2, 2, 2)
self.pose_bone.bbone_curveinx = 2
self.pose_bone[_CUSTOM_PROP] = 2.0
self._keyframe_all()
action = self.armature_ob.animation_data.action
slot = self.armature_ob.animation_data.action_slot
channelbag = anim_utils.action_get_channelbag_for_slot(action, slot)
self.assertEqual(len(channelbag.fcurves), 11)
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.scene.frame_set(1)
with bpy.context.temp_override(**_get_view3d_context()):
# The prev and next frames have to be specified in order for this to work correctly.
bpy.ops.pose.breakdown(factor=0.5, prev_frame=0, next_frame=10)
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.scale[i], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.bbone_curveinx, 1.5, 3)
self.assertAlmostEqual(self.pose_bone[_CUSTOM_PROP], 1.5, 3)
def test_location(self):
# The pose slide operators can constrain to a single property type.
# All other properties should not be modified.
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self.pose_bone.rotation_euler = (1, 1, 1)
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self.pose_bone.rotation_euler = (2, 2, 2)
self._keyframe_all()
bpy.context.scene.tool_settings.use_keyframe_insert_auto = True
bpy.context.scene.frame_set(1)
with bpy.context.temp_override(**_get_view3d_context()):
# The prev and next frames have to be specified in order for this to work correctly.
bpy.ops.pose.breakdown(factor=0.5, prev_frame=0, next_frame=10, channels='LOC')
for i in range(3):
self.assertAlmostEqual(self.pose_bone.location[i], 1.5, 3)
# Rotation should not be modified.
self.assertAlmostEqual(self.pose_bone.rotation_euler[i], 1.0279, 3)
def test_location_x(self):
# The slider operators can constrain to a single axis.
bpy.context.scene.frame_set(0)
self.pose_bone.location = (1, 1, 1)
self._keyframe_all()
bpy.context.scene.frame_set(10)
self.pose_bone.location = (2, 2, 2)
self._keyframe_all()
bpy.context.scene.frame_set(1)
with bpy.context.temp_override(**_get_view3d_context()):
# The prev and next frames have to be specified in order for this to work correctly.
bpy.ops.pose.breakdown(factor=0.5, prev_frame=0, next_frame=10, channels='LOC', axis_lock='X')
self.assertAlmostEqual(self.pose_bone.location[0], 1.5, 3)
self.assertAlmostEqual(self.pose_bone.location[1], 1.0279, 3)
self.assertAlmostEqual(self.pose_bone.location[2], 1.0279, 3)
def test_quaternion_axis_lock_limitation(self):
# Axis lock does not work with quaternions. This test just confirms that.
self.pose_bone.rotation_mode = 'QUATERNION'
bpy.context.scene.frame_set(0)
self.pose_bone.rotation_quaternion = (1, 0, 0, 0)
self.pose_bone.keyframe_insert("rotation_quaternion")
bpy.context.scene.frame_set(10)
self.pose_bone.rotation_quaternion = (0, 1, 0, 0)
self.pose_bone.keyframe_insert("rotation_quaternion")
with bpy.context.temp_override(**_get_view3d_context()):
# The prev and next frames have to be specified in order for this to work correctly.
bpy.ops.pose.breakdown(factor=0.0, prev_frame=0, next_frame=10, channels='ROT', axis_lock='X')
# Despite setting the axis lock, all channels are modified.
self.assertAlmostEqual(self.pose_bone.rotation_quaternion[0], 1, 3)
self.assertAlmostEqual(self.pose_bone.rotation_quaternion[1], 0, 3)
self.assertAlmostEqual(self.pose_bone.rotation_quaternion[2], 0, 3)
self.assertAlmostEqual(self.pose_bone.rotation_quaternion[3], 0, 3)
if __name__ == "__main__":
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
unittest.main()