fix(assets): address PR #96 review — all 8 items

- Fix stale 21→18 segment count in architecture doc, script docstrings,
  and disk budget table (critical — both reviewers)
- Add average_m/README.md noting role as clothing reference body
- Add __main__ guards to diagnostic scripts
- Preflight-check all source paths in blender_process_bodies.py
- Document solid-white hair masks as v0.2 placeholder for multi-region
- Add fallback size comment for head mask generation
- Add bald_mask.png (1×1 black) for sidecar convention consistency
- Document child non-uniform scale rationale (Y=0.72 vs XZ=0.75)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 15:24:50 +01:00
co-authored by Claude Opus 4.6
parent 8f7e0f09b0
commit 0b1bfe6a96
11 changed files with 114 additions and 65 deletions
+11 -10
View File
@@ -7,13 +7,14 @@ Lists all mesh objects in a FullBody GLTF: names, vertex counts, vertex group co
import sys
import bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
print("Scene objects:")
for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
if obj.type == 'MESH':
print(f" MESH: {obj.name!r} verts={len(obj.data.vertices)} vgroups={len(obj.vertex_groups)}")
else:
print(f" {obj.type}: {obj.name!r}")
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
print("Scene objects:")
for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
if obj.type == 'MESH':
print(f" MESH: {obj.name!r} verts={len(obj.data.vertices)} vgroups={len(obj.vertex_groups)}")
else:
print(f" {obj.type}: {obj.name!r}")
+14 -13
View File
@@ -8,17 +8,18 @@ for the segmentation script.
import sys
import bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and obj.vertex_groups:
print(f"MESH: {obj.name} — {len(obj.vertex_groups)} vertex groups, {len(obj.data.vertices)} vertices")
for vg in sorted(obj.vertex_groups, key=lambda x: x.name):
print(f" VG: {vg.name}")
break
else:
print("No skinned mesh found.")
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
for obj in bpy.context.scene.objects:
print(f" {obj.name} ({obj.type})")
if obj.type == 'MESH' and obj.vertex_groups:
print(f"MESH: {obj.name} — {len(obj.vertex_groups)} vertex groups, {len(obj.data.vertices)} vertices")
for vg in sorted(obj.vertex_groups, key=lambda x: x.name):
print(f" VG: {vg.name}")
break
else:
print("No skinned mesh found.")
for obj in bpy.context.scene.objects:
print(f" {obj.name} ({obj.type})")
+22 -12
View File
@@ -6,12 +6,12 @@ Drives the full body segmentation pipeline for all 11 body types.
Calls blender_segment_body.py logic inline (same Blender session, one pass per body type).
Source GLTF files (pre-exported from Source tier, Godot - UE format):
<source_dir>/Regular_Male_FullBody.gltf -> average_m (21 segs)
<source_dir>/Regular_Female_FullBody.gltf -> average_f (21 segs)
<source_dir>/Superhero_Male_FullBody.gltf -> muscular_m (21 segs)
<source_dir>/Superhero_Female_FullBody.gltf -> muscular_f (21 segs)
<source_dir>/Teen_Male_FullBody.gltf -> teen_m (21 segs)
<source_dir>/Teen_Female_FullBody.gltf -> teen_f (21 segs)
<source_dir>/Regular_Male_FullBody.gltf -> average_m (18 segs)
<source_dir>/Regular_Female_FullBody.gltf -> average_f (18 segs)
<source_dir>/Superhero_Male_FullBody.gltf -> muscular_m (18 segs)
<source_dir>/Superhero_Female_FullBody.gltf -> muscular_f (18 segs)
<source_dir>/Teen_Male_FullBody.gltf -> teen_m (18 segs)
<source_dir>/Teen_Female_FullBody.gltf -> teen_f (18 segs)
Fork body types (vertex-level scale applied before segmentation):
Regular_Male + scale (0.82, 1.0, 0.88) -> thin_m
@@ -24,9 +24,9 @@ Fork body types are approximate (auto-generated from mesh scaling). They produce
visually distinct silhouettes but may need artist refinement for final quality.
A README is placed in each fork directory marking this status.
Output: <output_base_dir>/{body_type}/seg_{name}.glb (21 files × 11 types = 231 GLBs)
Output: <output_base_dir>/{body_type}/seg_{name}.glb (18 files × 11 types = 198 GLBs)
Decisions: D-159 (11 body types), D-160 (21 segments), D-164 (Source .blends as starting point)
Decisions: D-159 (11 body types), D-160 (18 segments), D-164 (Source .blends as starting point)
"""
import sys
@@ -96,16 +96,26 @@ if __name__ == "__main__":
source_dir = args[0]
output_base_dir = args[1]
# Preflight: check ALL source files exist before starting any processing,
# so all missing files are reported at once rather than failing mid-loop.
unique_sources = {gltf_filename for (gltf_filename, _, _, _) in BODY_MANIFEST}
missing = [
os.path.join(source_dir, f)
for f in sorted(unique_sources)
if not os.path.exists(os.path.join(source_dir, f))
]
if missing:
print("\nERROR: Missing source GLTF files:")
for path in missing:
print(f" {path}")
sys.exit(1)
results = []
for (gltf_filename, body_type, scale, is_fork) in BODY_MANIFEST:
gltf_path = os.path.join(source_dir, gltf_filename)
output_dir = os.path.join(output_base_dir, body_type)
if not os.path.exists(gltf_path):
print(f"\nERROR: GLTF not found: {gltf_path}")
sys.exit(1)
print(f"\n{'='*60}")
print(f" Body type: {body_type} {'[FORK]' if is_fork else '[DIRECT]'}")
print(f" Source: {gltf_filename}")
+7 -1
View File
@@ -109,7 +109,13 @@ def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None:
def make_mask_png(mask_path: str, width: int = 1024, height: int = 1024) -> None:
"""Generate a solid-white greyscale mask PNG (entire mesh = tintable region)."""
"""Generate a solid-white greyscale mask PNG (entire mesh = tintable region).
Solid white = fully tintable. This is correct for v0.2 where each hair style
has a single tint color. The mask format supports greyscale bands for multi-region
recoloring (e.g. roots vs tips as separate regions) when needed in future — no
re-export required, just a shader update to sample distinct greyscale values.
"""
mask_img = bpy.data.images.new(
name="hair_mask",
width=width,
+7 -1
View File
@@ -54,13 +54,19 @@ def process_head(blend_path: str, output_dir: str, head_id: str) -> None:
print("ERROR: No mesh found in the .blend file")
sys.exit(1)
# Detect texture resolution for mask sizing
# Detect texture resolution for mask sizing.
# NOTE: This picks the first non-HDR texture found in bpy.data.images, which is
# fragile — iteration order is not guaranteed and multiple textures may be present.
# If no texture is found, falls back to MASK_RESOLUTION (512x512 default) so the
# mask is still generated at a sensible size rather than failing.
mask_w = mask_h = MASK_RESOLUTION
for img in bpy.data.images:
if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'):
print(f" Texture found: {img.name} — {img.size[0]}x{img.size[1]}")
mask_w, mask_h = img.size[0], img.size[1]
break
else:
print(f" No texture found — using fallback mask size {mask_w}x{mask_h}")
# Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them)
for obj in all_objects:
+4 -4
View File
@@ -4,7 +4,7 @@ Usage:
tooling/blender --background --python tooling/blender_segment_body.py -- \
<input.gltf> <output_dir> [--scale sx sy sz]
Segments a Quaternius FullBody GLTF into 21 production GLBs:
Segments a Quaternius FullBody GLTF into 18 production GLBs:
seg_head, seg_neck, seg_torso, seg_torso_upper,
seg_arm_upper_l/r, seg_arm_lower_l/r, seg_hand_l/r,
seg_leg_upper_l/r, seg_leg_lower_l/r, seg_foot_l/r,
@@ -16,9 +16,9 @@ plus 1-ring boundary overlap for seam-free deformation.
Optional --scale sx sy sz applies a vertex-level scale to the mesh before
segmentation (for fork body types: thin, heavy, child).
Outputs: <output_dir>/seg_{name}.glb (21 files total)
Outputs: <output_dir>/seg_{name}.glb (18 files total)
Design: D-160 (21 segments per body type), D-164 (Source .blends as starting point)
Design: D-160 (18 segments per body type), D-164 (Source .blends as starting point)
"""
import sys
@@ -249,7 +249,7 @@ def segment_special_object(special_obj, armature, output_path):
def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
"""Main entry: load GLTF, apply optional scale, produce all 21 segment GLBs."""
"""Main entry: load GLTF, apply optional scale, produce all 18 segment GLBs."""
print(f"\n Loading: {os.path.basename(gltf_path)}")
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=gltf_path)
+16 -14
View File
@@ -1,18 +1,20 @@
"""List all objects in a Quaternius .blend file."""
import sys, bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.open_mainfile(filepath=args[0])
print("\nAll scene objects:")
for o in sorted(bpy.context.scene.objects, key=lambda x: x.name):
parent_info = f" (parent: {o.parent.name}" + (f", bone: {o.parent_bone}" if o.parent_bone else "") + ")" if o.parent else ""
verts = f", {len(o.data.vertices)} verts" if o.type == 'MESH' else ""
loc = tuple(round(v, 3) for v in o.matrix_world.translation)
print(f" {o.name:30s} type={o.type:10s} loc={loc}{verts}{parent_info}")
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.open_mainfile(filepath=args[0])
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
if arm:
head_bone = arm.data.bones.get('Head')
if head_bone:
print(f"\nHead bone top: z={round(head_bone.tail_local[2], 4)}")
print("\nAll scene objects:")
for o in sorted(bpy.context.scene.objects, key=lambda x: x.name):
parent_info = f" (parent: {o.parent.name}" + (f", bone: {o.parent_bone}" if o.parent_bone else "") + ")" if o.parent else ""
verts = f", {len(o.data.vertices)} verts" if o.type == 'MESH' else ""
loc = tuple(round(v, 3) for v in o.matrix_world.translation)
print(f" {o.name:30s} type={o.type:10s} loc={loc}{verts}{parent_info}")
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
if arm:
head_bone = arm.data.bones.get('Head')
if head_bone:
print(f"\nHead bone top: z={round(head_bone.tail_local[2], 4)}")