Animation: New Euler filter implementation
This new discontinuity filter performs actions on the entire Euler rotation, rather than only on the individual X/Y/Z channels. This makes it fix a wider range of discontinuities, for example those in T52744. The filter now runs twice on the selected channels, in this order: - New: Convert X+Y+Z rotation to matrix, then back to Euler angles. - Old: Add/remove factors of 360° to minimize jumps. The messaging is streamlined; it now reports how many channels were filtered, and only warns (instead of errors) when there was an actual problem with the selected channels (like selecting three or more channels, but without X/Y/Z triplet). A new kernel function `BKE_fcurve_keyframe_move_value_with_handles()` is introduced, to make it possible to move a keyframe's value and move its handles at the same time. Manifest Task: T52744 Reviewed By: looch Differential Revision: https://developer.blender.org/D9602
This commit is contained in:
@@ -25,11 +25,13 @@ blender -b -noaudio --factory-startup --python tests/python/bl_animation_fcurves
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
from math import degrees, radians
|
||||
from typing import List
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
class FCurveEvaluationTest(unittest.TestCase):
|
||||
class AbstractAnimationTest:
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.testdir = args.testdir
|
||||
@@ -38,6 +40,7 @@ class FCurveEvaluationTest(unittest.TestCase):
|
||||
self.assertTrue(self.testdir.exists(),
|
||||
'Test dir %s should exist' % self.testdir)
|
||||
|
||||
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"))
|
||||
@@ -73,6 +76,97 @@ class FCurveEvaluationTest(unittest.TestCase):
|
||||
self.assertAlmostEqual(1.0, fcurve.evaluate(10))
|
||||
|
||||
|
||||
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()
|
||||
|
||||
## 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)
|
||||
|
||||
bpy.ops.graph.euler_filter(self.get_context())
|
||||
|
||||
# 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()
|
||||
|
||||
## 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)
|
||||
|
||||
bpy.ops.graph.euler_filter(self.get_context())
|
||||
|
||||
# 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
|
||||
action = ob.animation_data.action
|
||||
return [action.fcurves.find('rotation_euler', index=idx) for idx in range(3)]
|
||||
|
||||
|
||||
def main():
|
||||
global args
|
||||
import argparse
|
||||
|
Reference in New Issue
Block a user