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
@@ -0,0 +1,16 @@
# average_m — Canonical Clothing Reference Body
`average_m` is the **reference body type** for clothing authoring (D-159).
All clothing items are authored on `average_m` first. The resulting mesh is
saved as `clothing/<item_id>/reference.glb`. From there, the Surface Deform
batch pipeline fits it to the remaining 10 body types automatically, producing
`thin_m.glb`, `thin_f.glb`, `average_f.glb`, etc.
This means:
- Every clothing item's `reference.glb` was sculpted/modelled for this body
- `clothing/<item_id>/average_m.glb` is a direct copy of `reference.glb`
- Do not modify `average_m` body segments without re-running the clothing fit
See `docs/architecture/character-asset-organization.md` Section 5 (Surface Deform
Batch Output) for the full pipeline.
@@ -5,6 +5,13 @@ a proportional mesh scale to the source body (Teen_Male_FullBody.gltf):
Scale: (0.75, 0.72, 0.75)
The Y scale (0.72) differs from XZ (0.75) intentionally: children have different
proportions from adults. They are not simply uniformly smaller — their limbs are
shorter relative to torso height (Y) compared to torso width/depth (XZ). The
non-uniform scale captures this: the child is 75% of teen width/depth but only
72% of teen height, reflecting the head-to-body ratio and limb length differences
typical of pre-teen proportions.
These segments are APPROXIMATE. They produce a visually distinct silhouette
but are not artist-authored from scratch. Review the proportions and refine
the base mesh manually if the auto-scale result is not satisfactory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

@@ -116,7 +116,7 @@ client/assets/characters/
seg_eyes.glb
seg_eyebrows.glb
thin_f/
... # Same 21 segment files
... # Same 18 segment files
average_m/
...
average_f/
@@ -134,7 +134,7 @@ client/assets/characters/
heavy_f/
...
child/ # Gender-neutral, forked from Teen
... # Same 21 segments but different proportions
... # Same 18 segments but different proportions
skin_tones/
pale_cool.png # Skin tone texture variants
pale_warm.png # Shared across all body types --
@@ -243,9 +243,9 @@ split from hair into their own directories because they are separate equip slots
### Why this structure
**Bodies are directories, not flat files.** Each body type produces 21 segment GLBs (17 base
+ 2 swappable torso variants + 2 face detail). Putting them in flat `bodies/` with prefixes
(`thin_m_seg_head.glb`) would create 231 files in one directory. Directories per body type
**Bodies are directories, not flat files.** Each body type produces 18 segment GLBs (14 limb/body
segments + 2 torso variants + 2 face detail). Putting them in flat `bodies/` with prefixes
(`thin_m_seg_head.glb`) would create 198 files in one directory. Directories per body type
keep segment files scannable and let the compositor construct paths programmatically:
`bodies/{body_type}/seg_{segment}.glb`.
@@ -292,7 +292,7 @@ underscore-separated, no abbreviations beyond `m`/`f`. The GDScript and Rust enu
`ThinM`, `ThinF`, `AverageM`, `AverageF`, `MuscularM`, `MuscularF`, `TeenM`, `TeenF`,
`HeavyM`, `HeavyF`, `Child`.
### Body Segments (the 17 base + 2 swappable torso variants + 2 face detail)
### Body Segments (14 limb/body segments + 2 torso variants + 2 face detail = 18 total)
From the spike validation and D-160. Fine-grained segmentation is required for per-limb
addressability: amputation (hide limb, show stump cap), prosthetics (replace limb with
@@ -342,7 +342,7 @@ boundary with the lower leg segment above ensures smooth deformation at the join
The `seg_` prefix is load-bearing: it distinguishes segment GLBs from the `armature.glb` in
the same directory and makes glob patterns simple (`seg_*.glb`).
Total per body type: 17 base segments + 2 swappable torso variants + 2 face detail = **21 GLB files**.
Total per body type: 14 limb/body segments + 2 torso variants (full and upper) + 2 face detail = **18 GLB files**.
### Clothing Items
@@ -472,7 +472,7 @@ no branching.
| Animation libraries | **Shared** | 2+ GLBs | UAL1 (core) + UAL2 (extended + female mannequin); animations work on all body types via shared skeleton |
| Shaders (toon, toon_masked, outline) | **Shared** | 3 files | One copy in `characters/shaders/` |
| Skin tone textures | **Shared** | 9 PNGs | Toon shader tints these -- same textures for all body types |
| Body segments | **Per body type** | 21 GLBs x 11 types = 231 | Each body type has its own segments (incl. swappable torso variants) |
| Body segments | **Per body type** | 18 GLBs x 11 types = 198 | Each body type has its own segments (incl. swappable torso variants) |
| Clothing items | **Per body type** | 11 GLBs + 1 reference + 1 mask + 1 coverage per item | Authored once on reference, batch-fitted to 11 |
| Recolor masks (clothing) | **Shared per item** | 1 PNG per item | UV layout preserved through Surface Deform |
| Coverage metadata | **Shared per item** | 1 JSON per item | Declares which segments to hide and torso variant selection |
@@ -509,7 +509,7 @@ Blender: fork/modify body shape (for thin, heavy, child -- 6 of 11 types)
|-- OR: use as-is (average, muscular, teen -- 5 of 11 types)
|
v
Blender: fine-grained segmentation (17 base + 2 swappable torso + 2 face detail = 21 GLBs)
Blender: fine-grained segmentation (14 limb/body segments + 2 torso variants + 2 face detail = 18 GLBs)
|
v
Export GLB (per segment / per clothing variant / per accessory)
@@ -666,7 +666,7 @@ and torso variant selection.
|---|---|---|---|---|
| Skeleton | 1 | 1 | 1 | ~10 KB |
| Animations | 3 (UAL1 + UAL2 + future) | 1 | 3 | ~4 MB |
| Body segments | 11 types | 21 segs | 231 | ~2.2 MB |
| Body segments | 11 types | 18 segs | 198 | ~2.2 MB |
| Skin tones | 1 set | 9 | 9 | ~500 KB |
| Head templates | 50 (v0.2) | 2 (GLB+mask) | 100 | ~5 MB |
| Hair styles | 18 (v0.2, Source tier) | 2 (GLB+mask) | 36 | ~1.8 MB |
+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)}")