mirror of
https://projects.blender.org/blender/blender.git
synced 2025-01-22 07:22:12 -05:00
c0fd193abf
This PR renames `ActionSlot::name` to `ActionSlot::identifier` for both DNA and RNA, and also renames related methods, functions, constants, and comments. The purpose of this rename is to help make it clear that this is not a "name" in the traditional sense, but rather is a composite of the slot name + id type for lookup purposes. Ref: #130892 Pull Request: https://projects.blender.org/blender/blender/pulls/130740
434 lines
18 KiB
Python
434 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/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 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()
|