This change moves the tests data files and publish folder of assets repository to the main blender.git repository as LFS files. The goal of this change is to eliminate toil of modifying tests, cherry-picking changes to LFS branches, adding tests as part of a PR which brings new features or fixes. More detailed explanation and conversation can be found in the design task. Ref #137215 Pull Request: https://projects.blender.org/blender/blender/pulls/137219
435 lines
18 KiB
Python
435 lines
18 KiB
Python
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
"""
|
|
blender -b --factory-startup --python tests/python/bl_rigging_symmetrize.py -- --testdir /path/to/tests/files/animation
|
|
"""
|
|
|
|
import pathlib
|
|
import sys
|
|
import unittest
|
|
|
|
import bpy
|
|
|
|
|
|
def check_loc_rot_scale(self, bone, exp_bone):
|
|
# Check if posistions are the same
|
|
self.assertEqualVector(
|
|
bone.head, exp_bone.head, "Head position", bone.name)
|
|
self.assertEqualVector(
|
|
bone.tail, exp_bone.tail, "Tail position", bone.name)
|
|
|
|
# Scale
|
|
self.assertEqualVector(
|
|
bone.scale, exp_bone.scale, "Scale", bone.name)
|
|
|
|
# Rotation
|
|
rot_mode = exp_bone.rotation_mode
|
|
self.assertEqual(bone.rotation_mode, rot_mode, "Rotations mode does not match on bone %s" % (bone.name))
|
|
|
|
if rot_mode == 'QUATERNION':
|
|
self.assertEqualVector(
|
|
bone.rotation_quaternion, exp_bone.rotation_quaternion, "Quaternion rotation", bone.name)
|
|
elif rot_mode == 'AXIS_ANGLE':
|
|
self.assertEqualVector(
|
|
bone.axis_angle, exp_bone.axis_angle, "Axis Angle rotation", bone.name)
|
|
else:
|
|
# Euler rotation
|
|
self.assertEqualVector(
|
|
bone.rotation_euler, exp_bone.rotation_euler, "Euler rotation", bone.name)
|
|
|
|
|
|
def check_parent(self, bone, exp_bone):
|
|
self.assertEqual(type(bone.parent), type(exp_bone.parent),
|
|
"Mismatching types in pose.bones[%s].parent" % (bone.name))
|
|
self.assertTrue(bone.parent is None or bone.parent.name == exp_bone.parent.name,
|
|
"Bone parent does not match on bone %s" % (bone.name))
|
|
|
|
|
|
def check_bendy_bones(self, bone, exp_bone):
|
|
bone_variables = bone.bl_rna.properties.keys()
|
|
|
|
bendy_bone_variables = [
|
|
var for var in bone_variables if var.startswith("bbone_")]
|
|
|
|
for var in bendy_bone_variables:
|
|
value = getattr(bone, var)
|
|
exp_value = getattr(exp_bone, var)
|
|
|
|
self.assertEqual(type(value), type(exp_value),
|
|
"Mismatching types in pose.bones[%s].%s" % (bone.name, var))
|
|
|
|
if isinstance(value, str):
|
|
self.assertEqual(value, exp_value,
|
|
"Mismatching value in pose.bones[%s].%s" % (bone.name, var))
|
|
elif hasattr(value, "name"):
|
|
self.assertEqual(value.name, exp_value.name,
|
|
"Mismatching value in pose.bones[%s].%s" % (bone.name, var))
|
|
else:
|
|
self.assertAlmostEqual(value, exp_value,
|
|
"Mismatching value in pose.bones[%s].%s" % (bone.name, var))
|
|
|
|
|
|
def check_ik(self, bone, exp_bone):
|
|
bone_variables = bone.bl_rna.properties.keys()
|
|
prefixes = ("ik_", "lock_ik", "use_ik")
|
|
ik_bone_variables = (
|
|
var for var in bone_variables
|
|
if var.startswith(prefixes)
|
|
)
|
|
|
|
for var in ik_bone_variables:
|
|
value = getattr(bone, var)
|
|
exp_value = getattr(exp_bone, var)
|
|
self.assertAlmostEqual(value, exp_value,
|
|
"Mismatching value in pose.bones[%s].%s" % (bone.name, var))
|
|
|
|
|
|
def check_constraints(self, input_arm, expected_arm, bone, exp_bone):
|
|
const_len = len(bone.constraints)
|
|
expo_const_len = len(exp_bone.constraints)
|
|
|
|
self.assertEqual(const_len, expo_const_len,
|
|
"Constraints mismatch on bone %s" % (bone.name))
|
|
|
|
for exp_constraint in exp_bone.constraints:
|
|
const_name = exp_constraint.name
|
|
# Make sure that the constraint exists
|
|
self.assertTrue(const_name in bone.constraints,
|
|
"Bone %s is expected to contain constraint %s, but it does not." % (
|
|
bone.name, const_name))
|
|
constraint = bone.constraints[const_name]
|
|
const_variables = constraint.bl_rna.properties.keys()
|
|
|
|
for var in const_variables:
|
|
|
|
if var == "is_override_data":
|
|
# This variable is not used for local (non linked) data.
|
|
# For local object it is not initialized, so don't check this value.
|
|
continue
|
|
|
|
value = getattr(constraint, var)
|
|
exp_value = getattr(exp_constraint, var)
|
|
|
|
self.assertEqual(type(value), type(exp_value),
|
|
"Mismatching constraint value types in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var))
|
|
|
|
if isinstance(value, bpy.types.bpy_prop_collection):
|
|
# Don't compare collection properties.
|
|
continue
|
|
|
|
if isinstance(value, str):
|
|
self.assertEqual(value, exp_value,
|
|
"Mismatching constraint value in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var))
|
|
elif hasattr(value, "name"):
|
|
# Some constraints targets the armature itself, so the armature name should mismatch.
|
|
if value.name == input_arm.name and exp_value.name == expected_arm.name:
|
|
continue
|
|
|
|
self.assertEqual(value.name, exp_value.name,
|
|
"Mismatching constraint value in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var))
|
|
|
|
elif isinstance(value, bool):
|
|
self.assertEqual(value, exp_value,
|
|
"Mismatching constraint boolean in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var))
|
|
elif isinstance(value, float):
|
|
msg = "Mismatching constraint value in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var)
|
|
self.assertAlmostEqual(value, exp_value, places=6, msg=msg)
|
|
elif isinstance(value, int):
|
|
msg = "Mismatching constraint value in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var)
|
|
self.assertEqual(value, exp_value, msg=msg)
|
|
elif isinstance(value, bpy.types.ActionSlot):
|
|
msg = "Mismatching constraint ActionSlot in pose.bones[%s].constraints[%s].%s" % (
|
|
bone.name, const_name, var)
|
|
self.assertEqual(value, exp_value, msg=msg)
|
|
elif value is None:
|
|
# Since above the types were compared already, if value is none, so is exp_value.
|
|
pass
|
|
else:
|
|
self.fail(f"unexpected value type: {value!r} is of type {type(value)}")
|
|
|
|
|
|
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 ArmatureSymmetrizeTest(AbstractAnimationTest, unittest.TestCase):
|
|
def test_symmetrize_operator(self):
|
|
"""Test that the symmetrize operator is working correctly."""
|
|
bpy.ops.wm.open_mainfile(filepath=str(
|
|
self.testdir / "symm_test.blend"))
|
|
|
|
# #81541 (D9214)
|
|
arm = bpy.data.objects['transform_const_rig']
|
|
expected_arm = bpy.data.objects['expected_transform_const_rig']
|
|
self.assertEqualSymmetrize(arm, expected_arm)
|
|
|
|
# #66751 (D6009)
|
|
arm = bpy.data.objects['dragon_rig']
|
|
expected_arm = bpy.data.objects['expected_dragon_rig']
|
|
self.assertEqualSymmetrize(arm, expected_arm)
|
|
|
|
def assertEqualSymmetrize(self, input_arm, expected_arm):
|
|
|
|
# Symmetrize our input armature
|
|
bpy.context.view_layer.objects.active = input_arm
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.armature.select_all(action='SELECT')
|
|
bpy.ops.armature.symmetrize()
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Make sure that the bone count is the same
|
|
bone_len = len(input_arm.pose.bones)
|
|
expected_bone_len = len(expected_arm.pose.bones)
|
|
self.assertEqual(bone_len, expected_bone_len,
|
|
"Expected bone count to match")
|
|
|
|
for exp_bone in expected_arm.pose.bones:
|
|
bone_name = exp_bone.name
|
|
# Make sure that the bone exists
|
|
self.assertTrue(bone_name in input_arm.pose.bones,
|
|
"Armature is expected to contain bone %s, but it does not." % (bone_name))
|
|
bone = input_arm.pose.bones[bone_name]
|
|
|
|
# Loc Rot Scale
|
|
check_loc_rot_scale(self, bone, exp_bone)
|
|
|
|
# Parent settings
|
|
check_parent(self, bone, exp_bone)
|
|
|
|
# Bendy Bones
|
|
check_bendy_bones(self, bone, exp_bone)
|
|
|
|
# IK
|
|
check_ik(self, bone, exp_bone)
|
|
|
|
# Constraints
|
|
check_constraints(self, input_arm, expected_arm, bone, exp_bone)
|
|
|
|
def assertEqualVector(self, vec1, vec2, check_str, bone_name) -> None:
|
|
for idx, value in enumerate(vec1):
|
|
self.assertAlmostEqual(
|
|
value, vec2[idx], 3, "%s does not match with expected value on bone %s" % (check_str, bone_name))
|
|
|
|
|
|
def create_armature() -> tuple[bpy.types.Object, bpy.types.Armature]:
|
|
arm = bpy.data.armatures.new('Armature')
|
|
arm_ob = bpy.data.objects.new('ArmObject', arm)
|
|
|
|
# Link to the scene just for giggles. And ease of debugging when things
|
|
# go bad.
|
|
bpy.context.scene.collection.objects.link(arm_ob)
|
|
|
|
return arm_ob, arm
|
|
|
|
|
|
def set_edit_bone_selected(ebone: bpy.types.EditBone, selected: bool):
|
|
# Helper to select all parts of an edit bone.
|
|
ebone.select = selected
|
|
ebone.select_tail = selected
|
|
ebone.select_head = selected
|
|
|
|
|
|
def create_copy_loc_constraint(pose_bone, target_ob, subtarget):
|
|
pose_bone.constraints.new("COPY_LOCATION")
|
|
pose_bone.constraints[0].target = target_ob
|
|
pose_bone.constraints[0].subtarget = subtarget
|
|
|
|
|
|
class ArmatureSymmetrizeTargetsTest(unittest.TestCase):
|
|
arm_ob: bpy.types.Object
|
|
arm: bpy.types.Armature
|
|
|
|
def setUp(self):
|
|
bpy.ops.wm.read_homefile(use_factory_startup=True)
|
|
self.arm_ob, self.arm = create_armature()
|
|
bpy.context.view_layer.objects.active = self.arm_ob
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
ebone = self.arm.edit_bones.new(name="test.l")
|
|
ebone.tail = (1, 0, 0)
|
|
|
|
def test_symmetrize_selection(self):
|
|
# Only selected things are symmetrized.
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], False)
|
|
bpy.ops.armature.symmetrize()
|
|
self.assertEqual(len(self.arm.edit_bones), 1, "If nothing is selected, no bone is symmetrized")
|
|
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
bpy.ops.armature.symmetrize()
|
|
self.assertEqual(len(self.arm.edit_bones), 2, "Selected EditBone should have been symmetrized")
|
|
self.assertTrue("test.r" in self.arm.edit_bones)
|
|
self.assertTrue("test.l" in self.arm.edit_bones)
|
|
|
|
def test_symmetrize_constraint_sub_target(self):
|
|
# Explicitly test that constraints targeting another armature are symmetrized.
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
target_arm_ob, target_arm = create_armature()
|
|
bpy.context.view_layer.objects.active = target_arm_ob
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
target_arm.edit_bones.new("target.l")
|
|
target_arm.edit_bones.new("target.r")
|
|
target_arm.edit_bones["target.l"].tail = (1, 0, 0)
|
|
target_arm.edit_bones["target.r"].tail = (1, 0, 0)
|
|
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
bpy.context.view_layer.objects.active = self.arm_ob
|
|
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
pose_bone_l = self.arm_ob.pose.bones["test.l"]
|
|
create_copy_loc_constraint(pose_bone_l, target_arm_ob, "target.l")
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
bpy.ops.armature.symmetrize()
|
|
self.assertEqual(len(self.arm.edit_bones), 2, "Bone should have been symmetrized")
|
|
self.assertTrue("test.r" in self.arm.edit_bones)
|
|
self.assertTrue("test.l" in self.arm.edit_bones)
|
|
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
self.assertEqual(len(self.arm_ob.pose.bones["test.r"].constraints), 1, "Constraint should have been copied")
|
|
symm_constraint = self.arm_ob.pose.bones["test.r"].constraints[0]
|
|
self.assertEqual(symm_constraint.subtarget, "target.r")
|
|
|
|
def test_symmetrize_invalid_subtarget(self):
|
|
# Blender shouldn't crash when there is an invalid subtarget specified.
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
target_ob = bpy.data.objects.new("target", None)
|
|
bpy.context.scene.collection.objects.link(target_ob)
|
|
|
|
bpy.context.view_layer.objects.active = self.arm_ob
|
|
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
pose_bone_l = self.arm_ob.pose.bones["test.l"]
|
|
create_copy_loc_constraint(pose_bone_l, target_ob, "invalid_subtarget")
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
bpy.ops.armature.symmetrize()
|
|
self.assertEqual(len(self.arm.edit_bones), 2, "Bone should have been symmetrized")
|
|
self.assertTrue("test.r" in self.arm.edit_bones)
|
|
self.assertTrue("test.l" in self.arm.edit_bones)
|
|
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
self.assertEqual(len(self.arm_ob.pose.bones["test.r"].constraints), 1, "Constraint should have been copied")
|
|
symm_constraint = self.arm_ob.pose.bones["test.r"].constraints[0]
|
|
self.assertEqual(symm_constraint.subtarget, "invalid_subtarget")
|
|
|
|
|
|
class ArmatureSymmetrizeCollectionAssignments(unittest.TestCase):
|
|
arm_ob: bpy.types.Object
|
|
arm: bpy.types.Armature
|
|
|
|
def setUp(self):
|
|
bpy.ops.wm.read_homefile(use_factory_startup=True)
|
|
self.arm_ob, self.arm = create_armature()
|
|
bpy.context.view_layer.objects.active = self.arm_ob
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
ebone = self.arm.edit_bones.new(name="test.l")
|
|
ebone.tail = (1, 0, 0)
|
|
|
|
parent_coll = self.arm.collections.new("parent")
|
|
left_coll = self.arm.collections.new("collection.l", parent=parent_coll)
|
|
self.assertTrue(left_coll.assign(ebone))
|
|
self.assertEqual(len(ebone.collections), 1)
|
|
|
|
def test_symmetrize_to_existing_collection(self):
|
|
other_parent = self.arm.collections.new("other_parent")
|
|
right_coll = self.arm.collections.new("collection.r", parent=other_parent)
|
|
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
bpy.ops.armature.symmetrize()
|
|
|
|
right_bone = self.arm.edit_bones["test.r"]
|
|
self.assertEqual(len(right_bone.collections), 1)
|
|
self.assertEqual(right_bone.collections[0], right_coll)
|
|
|
|
# Parents should not be modified.
|
|
left_coll = self.arm.collections_all["collection.l"]
|
|
self.assertNotEqual(right_coll.parent, left_coll.parent)
|
|
|
|
def test_no_symmetrize(self):
|
|
# If the collection name cannot be flipped, nothing changes.
|
|
non_flip_collection = self.arm.collections.new("foobar")
|
|
left_bone = self.arm.edit_bones["test.l"]
|
|
self.arm.collections_all["collection.l"].unassign(left_bone)
|
|
self.assertTrue(non_flip_collection.assign(left_bone))
|
|
|
|
set_edit_bone_selected(left_bone, True)
|
|
bpy.ops.armature.symmetrize()
|
|
|
|
right_bone = self.arm.edit_bones["test.r"]
|
|
self.assertEqual(len(right_bone.collections), 1)
|
|
self.assertEqual(right_bone.collections[0], non_flip_collection)
|
|
|
|
def test_create_missing_collection(self):
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
self.assertFalse("collection.r" in self.arm.collections_all)
|
|
bpy.ops.armature.symmetrize()
|
|
|
|
# Missing collections are created.
|
|
self.assertTrue("collection.r" in self.arm.collections_all)
|
|
right_coll = self.arm.collections_all["collection.r"]
|
|
# When the collection is created, it is parented to the same collection as the source collection.
|
|
left_coll = self.arm.collections_all["collection.l"]
|
|
self.assertEqual(right_coll.parent, left_coll.parent)
|
|
|
|
right_bone = self.arm.edit_bones["test.r"]
|
|
self.assertEqual(len(right_bone.collections), 1)
|
|
self.assertEqual(right_bone.collections[0], right_coll)
|
|
|
|
def test_symmetrize_to_existing_bone(self):
|
|
right_bone = self.arm.edit_bones.new(name="test.r")
|
|
right_bone.tail = (1, 0, 0)
|
|
unique_right_coll = self.arm.collections.new("unique")
|
|
unique_right_coll.assign(right_bone)
|
|
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
bpy.ops.armature.symmetrize()
|
|
|
|
# Missing collection is created.
|
|
self.assertTrue("collection.r" in self.arm.collections_all)
|
|
self.assertEqual(len(right_bone.collections), 2)
|
|
self.assertTrue("collection.r" in right_bone.collections)
|
|
self.assertTrue("unique" in right_bone.collections,
|
|
"Mirrored bone shouldn't have lost the unique collection assignment")
|
|
|
|
# Symmetrizing twice shouldn't double invert the collection assignments.
|
|
set_edit_bone_selected(self.arm.edit_bones["test.l"], True)
|
|
set_edit_bone_selected(right_bone, False)
|
|
bpy.ops.armature.symmetrize()
|
|
self.assertTrue("collection.r" in right_bone.collections)
|
|
self.assertTrue("collection.l" not in right_bone.collections)
|
|
|
|
|
|
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()
|