From f0f609fe1bbebba67aee10952df6199c867f3753 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Wed, 4 Nov 2026 14:42:13 +0100 Subject: [PATCH] Add head subdivision variants and pick s48 after 1.5m/1.85m comparison --- TODO.md | 6 +++++- docs/3d-model-review.md | 14 ++++++++++++++ tools/npc_parts.py | 15 +++++++++------ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index dfbfd6e..bd7ef00 100644 --- a/TODO.md +++ b/TODO.md @@ -90,9 +90,13 @@ the rest of §C remains the broad review checklist. centre-profile evidence: nose tip −0.192, brow −0.150, chin −0.149, jaw step −0.11, forehead −0.126→−0.078. Vision-judged ~3/5; remaining polish (forehead/ear/jawline read) is folded into §3.2 comparison. -- [ ] Compare two or three subdivision/loop variants at 1.85 m front/three-quarter/profile and a +- [x] Compare two or three subdivision/loop variants at 1.85 m front/three-quarter/profile and a 1.5 m portrait. Pick the variant that reads best before changing all 12 characters; keep the rejected variants local. + Compared `head_proto` at seg 24 (670 verts), 48 (1,558 verts) and 64 (2,470 verts) at 1.85 m + and 1.5 m, front/three-quarter/profile. Vision-judged: **s48 wins** — s24 is too faceted + (nose/lips melt, jagged silhouette), s64 is visually identical to s48 at both distances + (extra ~900 verts wasted). Rejected variants stay in `tmp/npc-parts/`. - [ ] Verify head weights stay on the Head bone, headgear clearance still passes `fit`, and the face does not bake recessed creases into dark AO patches. - [ ] Get owner sign-off on the one-variant prototype, then roll the approved head/face into the diff --git a/docs/3d-model-review.md b/docs/3d-model-review.md index 3b24fa9..5035f22 100644 --- a/docs/3d-model-review.md +++ b/docs/3d-model-review.md @@ -232,6 +232,20 @@ brow −0.150, chin −0.149, jaw step −0.11, forehead −0.126→−0.078. Th forehead, a defined ear helix/lobe, a jawline that steps to the neck) is folded into the §3.2 variant comparison; the neck is part of `_skin_body`, so the standalone head always lacks it. +### Head subdivision comparison (2026-11-04) + +Three `head_proto` variants at UV-sphere segments 24/48/64 were rendered at 1.85 m and 1.5 m in +front/three-quarter/profile and judged side by side: + +| Variant | Verts / tris | 1.85 m | 1.5 m | Verdict | +| --- | ---: | --- | --- | --- | +| s24 | 670 / 1,168 | lumpy, nose/lips melt into the chin | jagged silhouette, no mouth | reject | +| s48 | 1,558 / 2,944 | eyes/brows/nose/lips read cleanly | features resolve cleanly | **pick** | +| s64 | 2,470 / 4,768 | visually identical to s48 | indistinguishable from s48 | reject (wasted verts) | + +The chosen s48 is the `head_proto` default; the rejected s24/s64 stay local under +`tmp/npc-parts/`. + ## Scorecard skeleton Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail), diff --git a/tools/npc_parts.py b/tools/npc_parts.py index 5c7b9a0..7b6a40f 100644 --- a/tools/npc_parts.py +++ b/tools/npc_parts.py @@ -93,18 +93,19 @@ def _export_obj(obj, path): return path -def _head_proto(mats, spec): +def _head_proto(mats, spec, subdiv=48): """Connected head shell with sculpted sockets, nose, lips, ears and brow/cheek/jaw. This is the §3 prototype: the skin features are displaced out of one head mesh instead of floating spheres, so the face is a single connected surface with loop-based eye sockets and protruding nose/lip/ear volumes. The eyeballs (sclera/iris/lids) stay separate — they are not skin. Blender Z-up, face - front −Y. + front −Y. `subdiv` is the UV-sphere u-segments (24/48/64 variants). """ skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] white = ba.material("eye_white", textured_solid=True) - head = ba.sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=48, rings=26, + rings = max(14, subdiv // 2 + 2) + head = ba.sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=subdiv, rings=rings, scale=(0.90, 0.96, 1.08)) ba._shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes bm = ba.bmesh.new() @@ -204,7 +205,7 @@ def _head_proto(mats, spec): return parts -def build_part(name, variant, out): +def build_part(name, variant, out, subdiv=48): """Build one part fresh from the current builders (Blender-only).""" mats, spec = _mats_and_spec(variant) if name == "head": @@ -214,7 +215,7 @@ def build_part(name, variant, out): elif name == "face": obj = ba.join(ba._face(mats, spec), "face") elif name == "head_proto": - obj = ba.join(_head_proto(mats, spec), "head_proto") + obj = ba.join(_head_proto(mats, spec, subdiv=subdiv), "head_proto") else: side = -1.0 if name == "hand_l" else 1.0 hs = ba._hands_and_shoes(mats, spec) @@ -378,6 +379,8 @@ def main(argv=None): s.add_argument("--part", required=True, choices=PART_NAMES) s.add_argument("--variant", default=PROTOTYPE) s.add_argument("--out", required=True) + s.add_argument("--subdiv", type=int, default=48, + help="head_proto UV-sphere u-segments (24/48/64 variants)") asm = sub.add_parser("assemble") asm.add_argument("--variant", default=PROTOTYPE) @@ -411,7 +414,7 @@ def main(argv=None): return code ba.reset_scene() if args.cmd == "build": - build_part(args.part, args.variant, args.out) + build_part(args.part, args.variant, args.out, subdiv=args.subdiv) print(f"built part {args.part} -> {args.out}") elif args.cmd == "extract": extract_part(args.part, args.variant, args.out)