Add head subdivision variants and pick s48 after 1.5m/1.85m comparison
Full build / Import + headless tests (push) Successful in 43s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-04 14:42:13 +01:00
1 parent b386a21173
commit f0f609fe1b
3 files changed
+28 -7

No files matched your search

+5 -1
View File
@@ -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 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 −0.126→−0.078. Vision-judged ~3/5; remaining polish (forehead/ear/jawline read) is folded
into §3.2 comparison. 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 1.5 m portrait. Pick the variant that reads best before changing all 12 characters; keep the
rejected variants local. 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 - [ ] 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. 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 - [ ] Get owner sign-off on the one-variant prototype, then roll the approved head/face into the
+14
View File
@@ -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 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. 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 ## Scorecard skeleton
Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail), Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail),
+9 -6
View File
@@ -93,18 +93,19 @@ def _export_obj(obj, path):
return 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. """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 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 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 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 (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"] skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = ba.material("eye_white", textured_solid=True) 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)) scale=(0.90, 0.96, 1.08))
ba._shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes ba._shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes
bm = ba.bmesh.new() bm = ba.bmesh.new()
@@ -204,7 +205,7 @@ def _head_proto(mats, spec):
return parts 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).""" """Build one part fresh from the current builders (Blender-only)."""
mats, spec = _mats_and_spec(variant) mats, spec = _mats_and_spec(variant)
if name == "head": if name == "head":
@@ -214,7 +215,7 @@ def build_part(name, variant, out):
elif name == "face": elif name == "face":
obj = ba.join(ba._face(mats, spec), "face") obj = ba.join(ba._face(mats, spec), "face")
elif name == "head_proto": 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: else:
side = -1.0 if name == "hand_l" else 1.0 side = -1.0 if name == "hand_l" else 1.0
hs = ba._hands_and_shoes(mats, spec) 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("--part", required=True, choices=PART_NAMES)
s.add_argument("--variant", default=PROTOTYPE) s.add_argument("--variant", default=PROTOTYPE)
s.add_argument("--out", required=True) 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 = sub.add_parser("assemble")
asm.add_argument("--variant", default=PROTOTYPE) asm.add_argument("--variant", default=PROTOTYPE)
@@ -411,7 +414,7 @@ def main(argv=None):
return code return code
ba.reset_scene() ba.reset_scene()
if args.cmd == "build": 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}") print(f"built part {args.part} -> {args.out}")
elif args.cmd == "extract": elif args.cmd == "extract":
extract_part(args.part, args.variant, args.out) extract_part(args.part, args.variant, args.out)