Sybren A. Stüvel
e4ca1fc4ea
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
188 lines
7.4 KiB
Python
188 lines
7.4 KiB
Python
# ##### BEGIN GPL LICENSE BLOCK #####
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
# ##### END GPL LICENSE BLOCK #####
|
|
|
|
# <pep8 compliant>
|
|
|
|
"""
|
|
blender -b -noaudio --factory-startup --python tests/python/bl_animation_fcurves.py -- --testdir /path/to/lib/tests/animation
|
|
"""
|
|
|
|
import pathlib
|
|
import sys
|
|
import unittest
|
|
from math import degrees, radians
|
|
from typing import List
|
|
|
|
import bpy
|
|
|
|
|
|
class AbstractAnimationTest:
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.testdir = args.testdir
|
|
|
|
def setUp(self):
|
|
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"))
|
|
cube = bpy.data.objects['Cube']
|
|
fcurve = cube.animation_data.action.fcurves.find('location', index=0)
|
|
|
|
self.assertAlmostEqual(0.0, fcurve.evaluate(1))
|
|
self.assertAlmostEqual(0.019638698548078537, fcurve.evaluate(2))
|
|
self.assertAlmostEqual(0.0878235399723053, fcurve.evaluate(3))
|
|
self.assertAlmostEqual(0.21927043795585632, fcurve.evaluate(4))
|
|
self.assertAlmostEqual(0.41515052318573, fcurve.evaluate(5))
|
|
self.assertAlmostEqual(0.6332430243492126, fcurve.evaluate(6))
|
|
self.assertAlmostEqual(0.8106040954589844, fcurve.evaluate(7))
|
|
self.assertAlmostEqual(0.924369215965271, fcurve.evaluate(8))
|
|
self.assertAlmostEqual(0.9830065965652466, fcurve.evaluate(9))
|
|
self.assertAlmostEqual(1.0, fcurve.evaluate(10))
|
|
|
|
def test_fcurve_extreme_handles(self):
|
|
# See D8752.
|
|
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "fcurve-extreme-handles.blend"))
|
|
cube = bpy.data.objects['Cube']
|
|
fcurve = cube.animation_data.action.fcurves.find('location', index=0)
|
|
|
|
self.assertAlmostEqual(0.0, fcurve.evaluate(1))
|
|
self.assertAlmostEqual(0.004713400732725859, fcurve.evaluate(2))
|
|
self.assertAlmostEqual(0.022335870191454887, fcurve.evaluate(3))
|
|
self.assertAlmostEqual(0.06331237405538559, fcurve.evaluate(4))
|
|
self.assertAlmostEqual(0.16721539199352264, fcurve.evaluate(5))
|
|
self.assertAlmostEqual(0.8327845335006714, fcurve.evaluate(6))
|
|
self.assertAlmostEqual(0.9366875886917114, fcurve.evaluate(7))
|
|
self.assertAlmostEqual(0.9776642322540283, fcurve.evaluate(8))
|
|
self.assertAlmostEqual(0.9952865839004517, fcurve.evaluate(9))
|
|
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
|
|
|
|
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()
|