When symmetrizing a bone (in edit mode) any constraint subtargets were name flipped, if possible and they exist. This only worked if the target is the armature of the bone being flipped. This patch changes that so a subtarget flip is always attempted for targets that are an armature. Pull Request: https://projects.blender.org/blender/blender/pulls/129169
345 lines
14 KiB
Python
345 lines
14 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/data/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 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")
|
|
|
|
|
|
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()
|