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:
@@ -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")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
blender_extract_armature.py
|
||||
Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>
|
||||
|
||||
Loads a Quaternius GLTF file, strips all mesh objects and animation data,
|
||||
and exports only the armature as a GLB skeleton file.
|
||||
|
||||
Requirements:
|
||||
- 65-bone hierarchy preserved
|
||||
- No mesh geometry
|
||||
- No animation data
|
||||
- Y-up, -Z forward (glTF default)
|
||||
- 1 unit = 1 meter (Quaternius convention)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
def extract_armature(input_path: str, output_path: str) -> None:
|
||||
# Clear the default scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import the GLTF/GLB
|
||||
print(f"Loading: {input_path}")
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
# Report what was imported
|
||||
all_objects = list(bpy.context.scene.objects)
|
||||
print(f"Imported {len(all_objects)} objects:")
|
||||
for obj in all_objects:
|
||||
print(f" {obj.name} ({obj.type})")
|
||||
|
||||
# Find armature objects
|
||||
armatures = [obj for obj in all_objects if obj.type == 'ARMATURE']
|
||||
if not armatures:
|
||||
print("ERROR: No armature found in the imported file.")
|
||||
sys.exit(1)
|
||||
|
||||
armature = armatures[0]
|
||||
print(f"Armature: {armature.name} — {len(armature.data.bones)} bones")
|
||||
|
||||
if len(armature.data.bones) < 60:
|
||||
print(f"WARNING: Expected ~65 bones, found {len(armature.data.bones)}. Check compatibility.")
|
||||
|
||||
# Remove all non-armature objects (meshes, lights, cameras, empties)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in all_objects:
|
||||
if obj.type != 'ARMATURE':
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Remove all animation data (skeleton only, no poses)
|
||||
if armature.animation_data:
|
||||
armature.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
# Select only the armature for export
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Export as GLB — skeleton only, no animations, no meshes
|
||||
print(f"Exporting to: {output_path}")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
)
|
||||
|
||||
# Verify: report bone count and names
|
||||
bones = sorted(armature.data.bones, key=lambda b: b.name)
|
||||
print(f"Export complete. {len(bones)} bones:")
|
||||
for bone in bones:
|
||||
print(f" {bone.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide input GLTF/GLB path and output GLB path.")
|
||||
sys.exit(1)
|
||||
|
||||
extract_armature(args[0], args[1])
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
blender_list_animations.py
|
||||
Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>
|
||||
|
||||
Lists all animation actions in a GLB file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
actions = list(bpy.data.actions)
|
||||
print("ACTIONS_COUNT=" + str(len(actions)))
|
||||
for action in sorted(actions, key=lambda a: a.name):
|
||||
print("ACTION: " + action.name)
|
||||
|
||||
# Also report armature bone count if present
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
print("ARMATURE_BONES=" + str(len(obj.data.bones)))
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=sys.argv[sys.argv.index("--") + 1])
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
for b in sorted(obj.data.bones, key=lambda x: x.name):
|
||||
print("BONE: " + b.name)
|
||||
break
|
||||
Reference in New Issue
Block a user