blender/tests/python/bl_rigging_symmetrize.py
Sergey Sharybin bbfc97ad6f Move tests/data and assets to the main repository
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
2025-05-05 15:10:22 +02:00

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()