feat(client): CharacterVisualDescriptor and compositor (#703, #704)

Add CharacterVisualDescriptor (11-variant BodyType enum, wire format
encode/decode, body_type_key mapping) and CharacterVisual compositor
(runtime 3D character assembler with slot architecture, BoneAttachment3D,
clothing coverage, recolor mask loading, skin tone tinting, bone
validation). Includes 64 unit tests and Blender utility scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 07:48:47 +01:00
co-authored by Claude Opus 4.6
parent ff3a8e95e4
commit 23ec1ddebd
8 changed files with 1951 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
"""
blender_compare_bones.py
Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <file_a.glb> <file_b.glb>
Compares bone names between two GLB/GLTF files. Reports missing or extra bones.
"""
import sys
import bpy
def get_bones(path: str) -> set:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=path)
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
return {b.name for b in obj.data.bones}
return set()
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <a.glb> <b.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Need two GLB/GLTF paths.")
sys.exit(1)
path_a, path_b = args[0], args[1]
print(f"Loading A: {path_a}")
bones_a = get_bones(path_a)
print(f"A has {len(bones_a)} bones")
print(f"Loading B: {path_b}")
bones_b = get_bones(path_b)
print(f"B has {len(bones_b)} bones")
only_in_a = bones_a - bones_b
only_in_b = bones_b - bones_a
shared = bones_a & bones_b
print(f"Shared: {len(shared)}")
if only_in_a:
print(f"Only in A ({len(only_in_a)}):")
for name in sorted(only_in_a):
print(f" - {name}")
if only_in_b:
print(f"Only in B ({len(only_in_b)}):")
for name in sorted(only_in_b):
print(f" + {name}")
if not only_in_a and not only_in_b:
print("BONE_MATCH=OK")
else:
print("BONE_MATCH=MISMATCH")