Merge branch 'blender-v5.0-release'

This commit is contained in:
Weizhen Huang
2025-10-13 12:15:33 +02:00
4 changed files with 241 additions and 17 deletions

View File

@@ -7,8 +7,13 @@ blender -b --factory-startup --python tests/python/bl_animation_armature.py
"""
import unittest
from typing import TypeAlias
import bpy
from mathutils import Vector
from bpy.types import EditBone, Object, Armature
Vectorish: TypeAlias = Vector | tuple[float, float, float]
class BoneCollectionTest(unittest.TestCase):
@@ -222,6 +227,183 @@ class BoneCollectionTest(unittest.TestCase):
self.assertEqual({'agent': 327}, bcolls_all['child3']['dict'].to_dict())
class ArmatureCreationTest(unittest.TestCase):
arm_ob: Object
arm: Armature
def setUp(self) -> None:
print("\033[92mloading empty homefile\033[0m")
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
self.arm_ob, self.arm = self.create_armature()
@staticmethod
def create_armature() -> tuple[Object, Armature]:
"""Create an Armature without any bones."""
arm = bpy.data.armatures.new('Armature')
arm_ob = bpy.data.objects.new('ArmObject', arm)
# Remove any pre-existing bone.
while arm.bones:
arm.bones.remove(arm.bones[0])
bpy.context.scene.collection.objects.link(arm_ob)
bpy.context.view_layer.objects.active = arm_ob
return arm_ob, arm
def create_bone(self, name: str, parent: EditBone | None, head: Vectorish, tail: Vectorish) -> EditBone:
bone = self.arm.edit_bones.new(name)
bone.parent = parent
bone.head = head
bone.tail = tail
bone.use_connect = True
return bone
def test_tiny_bones(self) -> None:
"""Tiny bones should be elongated."""
# 'bpy.context.active_object' does not exist when Blender is running in
# GUI mode. That's not the normal way to run this test, but very useful
# to be able to do for debugging purposes.
with bpy.context.temp_override(active_object=self.arm_ob):
bpy.ops.object.mode_set(mode='EDIT')
# Constants defined in `ED_armature_from_edit()`:
bone_length_threshold = 0.000001
adjusted_bone_length = 2 * bone_length_threshold
# A value for which the vector (under_threshold, 0, under_threshold)
# is still shorter than the bone length threshold.
under_threshold = 0.0000006
root = self.create_bone("root", None, (0, 0, 0), (0, 0, 1))
tinychild_1 = self.create_bone(
"tinychild_1",
root,
root.tail,
root.tail + Vector((0, under_threshold, 0)),
)
self.create_bone(
"tinychild_2",
root,
root.tail,
root.tail + Vector((under_threshold, 0, under_threshold)),
)
self.create_bone(
"zerochild_3",
root,
root.tail,
root.tail,
)
# Give a tiny child a grandchild that is also tiny, in a perpendicular direction.
self.create_bone(
"tinygrandchild_1_1",
tinychild_1,
tinychild_1.tail,
tinychild_1.tail + Vector((under_threshold, 0, 0)),
)
# Add a grandchild that is long enough.
grandchild_1_2 = self.create_bone(
"grandchild_1_2",
tinychild_1,
tinychild_1.tail,
tinychild_1.tail + Vector((1, 0, 0)),
)
# Add a great-grandchild, it should remain connected to its parent.
self.create_bone(
"great_grandchild_1_2_1",
grandchild_1_2,
grandchild_1_2.tail,
grandchild_1_2.tail + Vector((1, 0, 0)),
)
# Switch out and back into Armature Edit mode, to see how the bones survived the round-trip.
with bpy.context.temp_override(active_object=self.arm_ob):
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT')
# Check that all bones still exist, and have the expected head/tail. This
# comparison is done again in Armature Edit mode, so that all the numbers
# mean the same thing as they meant when creating the bones.
actual_names = sorted(bone.name for bone in self.arm.edit_bones)
expect_names = sorted(["root", "tinychild_1", "tinychild_2", "zerochild_3", "tinygrandchild_1_1",
"grandchild_1_2", "great_grandchild_1_2_1"])
self.assertEqual(expect_names, actual_names)
def check_bone(
name: str,
expect_head: Vectorish,
expect_tail: Vectorish,
*,
expect_connected: bool,
msg: str,
places=7,
) -> None:
bone = self.arm.edit_bones[name]
# Convert to tuples for nicer printing in failure messages.
actual_head = bone.head.to_tuple()
actual_tail = bone.tail.to_tuple()
head_msg = "\n{}:\n Expected head ({:.8f}, {:.8f}, {:.8f}),\n Actual is ({:.8f}, {:.8f}, {:.8f}).\n {}".format(
name, expect_head[0], expect_head[1], expect_head[2], actual_head[0], actual_head[1], actual_head[2], msg)
self.assertAlmostEqual(expect_head[0], actual_head[0], places=places, msg=head_msg)
self.assertAlmostEqual(expect_head[1], actual_head[1], places=places, msg=head_msg)
self.assertAlmostEqual(expect_head[2], actual_head[2], places=places, msg=head_msg)
# print("\n{}:\n Head is ({:.8f}, {:.8f}, {:.8f})".format(
# name, actual_head[0], actual_head[1], actual_head[2]))
tail_msg = "\n{}:\n Expected tail ({:.8f}, {:.8f}, {:.8f}),\n Actual is ({:.8f}, {:.8f}, {:.8f}).\n {}".format(
name, expect_tail[0], expect_tail[1], expect_tail[2], actual_tail[0], actual_tail[1], actual_tail[2], msg)
self.assertAlmostEqual(expect_tail[0], actual_tail[0], places=places, msg=tail_msg)
self.assertAlmostEqual(expect_tail[1], actual_tail[1], places=places, msg=tail_msg)
self.assertAlmostEqual(expect_tail[2], actual_tail[2], places=places, msg=tail_msg)
# print(" Tail is ({:.8f}, {:.8f}, {:.8f})".format(
# actual_tail[0], actual_tail[1], actual_tail[2]))
self.assertEqual(expect_connected, bone.use_connect, msg="{}: {}".format(bone.name, msg))
check_bone("root", (0, 0, 0), (0, 0, 1),
expect_connected=True, msg="Should not have changed.")
check_bone("tinychild_1", (0, 0, 1), (0, adjusted_bone_length, 1),
expect_connected=True, msg="Should have been elongated in the Y-direction")
adjust = (Vector((under_threshold, 0, under_threshold)).normalized() * adjusted_bone_length).x
check_bone("tinychild_2",
(0, 0, 1),
(adjust, 0, 1 + adjust),
expect_connected=True,
msg="Should have been elongated in the XZ-direction")
check_bone("zerochild_3",
(0, 0, 1),
(0, 0, 1 + adjusted_bone_length),
expect_connected=True,
msg="Should have been elongated in the Z-direction")
check_bone("tinygrandchild_1_1",
(0, under_threshold, 1),
(adjusted_bone_length, under_threshold, 1),
expect_connected=False,
msg="Should have been elongated in the X-direction and disconnected")
check_bone("grandchild_1_2",
(0, under_threshold, 1),
(1, under_threshold, 1),
expect_connected=False,
msg="Should been disconnected")
check_bone("great_grandchild_1_2_1",
(1, under_threshold, 1),
(2, under_threshold, 1),
expect_connected=True,
msg="Should been kept connected")
def main():
import sys
@@ -231,7 +413,7 @@ def main():
# Avoid passing all of Blender's arguments to unittest.main()
argv = [sys.argv[0]]
unittest.main(argv=argv)
unittest.main(argv=argv, exit=False)
if __name__ == "__main__":