From e4ca1fc4ea43f795441a319ea96b63a58553f070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Mon, 23 Nov 2020 12:48:04 +0100 Subject: Animation: New Euler filter implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/python/bl_animation_fcurves.py | 96 +++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) (limited to 'tests/python') diff --git a/tests/python/bl_animation_fcurves.py b/tests/python/bl_animation_fcurves.py index b5772b8d335..2ec04749d70 100644 --- a/tests/python/bl_animation_fcurves.py +++ b/tests/python/bl_animation_fcurves.py @@ -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 -- cgit v1.2.3