From 0b1bfe6a9620f31ada4473ba2ac70f48a5a34bfe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 22 Mar 2026 15:24:50 +0100 Subject: [PATCH] =?UTF-8?q?fix(assets):=20address=20PR=20#96=20review=20?= =?UTF-8?q?=E2=80=94=20all=208=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../characters/bodies/average_m/README.md | 16 +++++++++ .../assets/characters/bodies/child/README.md | 7 ++++ client/assets/characters/hair/bald_mask.png | Bin 0 -> 67 bytes .../character-asset-organization.md | 20 +++++------ tooling/blender_inspect_body.py | 21 +++++------ tooling/blender_inspect_vgroups.py | 27 +++++++------- tooling/blender_process_bodies.py | 34 +++++++++++------- tooling/blender_process_hair.py | 8 ++++- tooling/blender_process_heads.py | 8 ++++- tooling/blender_segment_body.py | 8 ++--- tooling/check_icosphere.py | 30 ++++++++-------- 11 files changed, 114 insertions(+), 65 deletions(-) create mode 100644 client/assets/characters/bodies/average_m/README.md create mode 100644 client/assets/characters/hair/bald_mask.png diff --git a/client/assets/characters/bodies/average_m/README.md b/client/assets/characters/bodies/average_m/README.md new file mode 100644 index 000000000..4f582f345 --- /dev/null +++ b/client/assets/characters/bodies/average_m/README.md @@ -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//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//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. diff --git a/client/assets/characters/bodies/child/README.md b/client/assets/characters/bodies/child/README.md index 5ad6f183d..d7982ca1e 100644 --- a/client/assets/characters/bodies/child/README.md +++ b/client/assets/characters/bodies/child/README.md @@ -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. diff --git a/client/assets/characters/hair/bald_mask.png b/client/assets/characters/hair/bald_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..aea7f5ff8ad3e3c7c084080bda8ac3b95e9a71cd GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CSbBp9sfW`_bPE>9Q7kcv6U2|zXz1Ea_KC51p1 NgQu&X%Q~loCIDk>43q!> literal 0 HcmV?d00001 diff --git a/docs/architecture/character-asset-organization.md b/docs/architecture/character-asset-organization.md index 7eaa28e8d..e407fc3e3 100644 --- a/docs/architecture/character-asset-organization.md +++ b/docs/architecture/character-asset-organization.md @@ -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 | diff --git a/tooling/blender_inspect_body.py b/tooling/blender_inspect_body.py index e54c170f9..0edf99b15 100644 --- a/tooling/blender_inspect_body.py +++ b/tooling/blender_inspect_body.py @@ -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}") diff --git a/tooling/blender_inspect_vgroups.py b/tooling/blender_inspect_vgroups.py index f9668ab49..ce2639920 100644 --- a/tooling/blender_inspect_vgroups.py +++ b/tooling/blender_inspect_vgroups.py @@ -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})") diff --git a/tooling/blender_process_bodies.py b/tooling/blender_process_bodies.py index 8f7e6f3b3..b73402224 100644 --- a/tooling/blender_process_bodies.py +++ b/tooling/blender_process_bodies.py @@ -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): - /Regular_Male_FullBody.gltf -> average_m (21 segs) - /Regular_Female_FullBody.gltf -> average_f (21 segs) - /Superhero_Male_FullBody.gltf -> muscular_m (21 segs) - /Superhero_Female_FullBody.gltf -> muscular_f (21 segs) - /Teen_Male_FullBody.gltf -> teen_m (21 segs) - /Teen_Female_FullBody.gltf -> teen_f (21 segs) + /Regular_Male_FullBody.gltf -> average_m (18 segs) + /Regular_Female_FullBody.gltf -> average_f (18 segs) + /Superhero_Male_FullBody.gltf -> muscular_m (18 segs) + /Superhero_Female_FullBody.gltf -> muscular_f (18 segs) + /Teen_Male_FullBody.gltf -> teen_m (18 segs) + /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: /{body_type}/seg_{name}.glb (21 files × 11 types = 231 GLBs) +Output: /{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}") diff --git a/tooling/blender_process_hair.py b/tooling/blender_process_hair.py index fee9bfa6b..1e2296a2a 100644 --- a/tooling/blender_process_hair.py +++ b/tooling/blender_process_hair.py @@ -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, diff --git a/tooling/blender_process_heads.py b/tooling/blender_process_heads.py index ccbdb224d..8df23acaf 100644 --- a/tooling/blender_process_heads.py +++ b/tooling/blender_process_heads.py @@ -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: diff --git a/tooling/blender_segment_body.py b/tooling/blender_segment_body.py index 3af24e74a..8e30086dd 100644 --- a/tooling/blender_segment_body.py +++ b/tooling/blender_segment_body.py @@ -4,7 +4,7 @@ Usage: tooling/blender --background --python tooling/blender_segment_body.py -- \ [--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: /seg_{name}.glb (21 files total) +Outputs: /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) diff --git a/tooling/check_icosphere.py b/tooling/check_icosphere.py index c424599cf..aa7cb7050 100644 --- a/tooling/check_icosphere.py +++ b/tooling/check_icosphere.py @@ -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)}")