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:
@@ -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 |
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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)}")
|
||||
|
||||
Reference in New Issue
Block a user