From 50edc47480f682862096dd1d0dd072d647c295b8 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Wed, 4 Nov 2026 14:23:19 +0100 Subject: [PATCH] Add the connected-shell head prototype (head_proto) with sculpted sockets/nose/lips/ears --- TODO.md | 7 +++- docs/3d-model-review.md | 13 +++++++ tools/npc_parts.py | 86 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index a83016e..f81a244 100644 --- a/TODO.md +++ b/TODO.md @@ -80,10 +80,15 @@ the rest of §C remains the broad review checklist. ### 3. Head and face prototype -- [ ] Build one `npc_casual` head/face prototype as a connected head shell plus intentional feature +- [-] Build one `npc_casual` head/face prototype as a connected head shell plus intentional feature geometry — not a sphere with floating facial balls. Use loop-based eye sockets/lids, a readable nose bridge/tip/ala, a mouth with upper/lower lip volumes, ears with concha depth, and brow/cheek/jaw planes that survive the 256² AO bake. + In progress: `tools/npc_parts.py build --part head_proto` displaces the shell (seg 48/rings 26) + into sockets, a nose bridge/tip/ala, upper/lower lips and ears; the eyeballs stay separate. + Vision-judged (iteration 2): the nose + ear now protrude in profile, but the cranium still + reads as a "ball with a nose". Next: flatten the face plane, taper the jaw/chin, widen the + nose bridge, and shape the back of the skull before the 1.5 m/1.85 m comparison. - [ ] 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. diff --git a/docs/3d-model-review.md b/docs/3d-model-review.md index 856c687..8eec6ab 100644 --- a/docs/3d-model-review.md +++ b/docs/3d-model-review.md @@ -210,6 +210,19 @@ Part sources and preview GLBs live under `tmp/npc-parts//` (gitignored committed. No new committed `.blend` source tree is added without a docs/ADR decision — the frozen `npc_base.blend` stays the only tracked `.blend`. +### Head prototype (in progress 2026-11-04) + +`tools/npc_parts.py build --part head_proto` is the first connected-shell attempt: a seg-48 +head sphere is displaced (not floating spheres) into eye sockets with an orbital rim, a nose +bridge/tip/ala, upper/lower lip volumes, and extruded ears with a concha recess; the eyeballs +stay separate. Local renders live under `tmp/npc-parts/npc_casual/head_proto_*.png`. + +Vision-judged after two iterations: the nose and ear now protrude in profile (mesh y-min +−0.186 vs the −0.155 shell front), but the cranium still reads as a "ball with a nose" — the +face plane is not flattened, the jaw/chin does not taper, and the nose reads as a narrow spike. +Next refinement before the 1.5 m/1.85 m comparison: flatten the face plane, taper the jaw/chin, +widen the nose bridge, and shape the back of the skull. + ## 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 6f42728..beeb94d 100644 --- a/tools/npc_parts.py +++ b/tools/npc_parts.py @@ -35,6 +35,7 @@ absolute and derived from this file's location. """ import argparse +import math import sys from pathlib import Path @@ -52,7 +53,7 @@ PROTOTYPE = "npc_casual" # canonical one-variant prototype # The four buildable parts. `head` is the bare head shell; `face` is the shell # plus nose/eyes/mouth/ears; `hand_l`/`hand_r` are the left/right hand geometry. -PART_NAMES = ("head", "face", "hand_l", "hand_r") +PART_NAMES = ("head", "face", "hand_l", "hand_r", "head_proto") _HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip") @@ -92,6 +93,85 @@ def _export_obj(obj, path): return path +def _head_proto(mats, spec): + """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. + """ + 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, + scale=(0.92, 1.0, 1.12)) + ba._shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes + bm = ba.bmesh.new() + bm.from_mesh(head.data) + + for v in bm.verts: + x, y, z = v.co + ax = abs(x) + # --- eye sockets: recess the floor, leave a raised orbital rim --- + for s in (-1.0, 1.0): + r = math.hypot(x - 0.052 * s, z - 1.664) + if r < 0.036: + v.co.y += 0.018 * (1.0 - r / 0.036) # recess inward (+y) + elif r < 0.046: + v.co.y -= 0.010 * (1.0 - (r - 0.036) / 0.010) # rim forward + # --- nose: bridge/tip protrusion plus ala wings --- + if y < 0 and ax < 0.048: + band = max(0.0, 1.0 - abs(z - 1.628) / 0.052) # 1.576..1.680 + if band > 0: + tip = max(0.0, 1.0 - abs(z - 1.615) / 0.022) + v.co.y -= (0.035 * tip + 0.012 * (band - tip)) * (1.0 - ax / 0.048) + for s in (-1.0, 1.0): + ar = math.hypot((x - 0.017 * s) / 0.011, (z - 1.606) / 0.011) + if ar < 1.0: + v.co.y -= 0.018 * (1.0 - ar) + # --- mouth: recessed line + upper/lower lip volumes --- + if y < 0 and ax < 0.030: + if abs(z - 1.583) < 0.012: + v.co.y += 0.008 # mouth slit recess + if abs(z - 1.590) < 0.008: + v.co.y -= 0.014 # upper lip + if abs(z - 1.577) < 0.010: + v.co.y -= 0.018 # fuller lower lip + # --- ears: extrude outward with a concha recess --- + for v in bm.verts: + x, y, z = v.co + for s in (-1.0, 1.0): + r = math.hypot((z - 1.63) / 0.055, (y - 0.008) / 0.045) + near = abs(x) - 0.140 + if -0.02 < near < 0.05 and r < 1.0: + if r < 0.35: + v.co.x -= 0.014 * (1.0 - r / 0.35) * s # concha recess (inward) + else: + v.co.x += 0.028 * (1.0 - r) * s # helix rim outward + bm.normal_update() + bm.to_mesh(head.data) + bm.free() + + # Eyeballs stay separate for readability; lids wrap the socket rim. + parts = [head] + for s in (-1.0, 1.0): + ex, ey, ez = 0.052 * s, -0.134, 1.664 + parts.append(ba.sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, + rings=6, scale=(1.25, 0.62, 0.85))) + parts.append(ba.sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, + rings=5, scale=(1.0, 0.55, 1.0))) + parts.append(ba.sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), + skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), + rot=(math.radians(-10), 0, 0))) + parts.append(ba.sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), + skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), + rot=(math.radians(6), 0, 0))) + parts.append(ba.box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), + hair, rot=(0, 0.14 * s, 0.0))) + return parts + + def build_part(name, variant, out): """Build one part fresh from the current builders (Blender-only).""" mats, spec = _mats_and_spec(variant) @@ -101,6 +181,8 @@ def build_part(name, variant, out): ba._shape_head_mesh(obj) elif name == "face": obj = ba.join(ba._face(mats, spec), "face") + elif name == "head_proto": + obj = ba.join(_head_proto(mats, spec), "head_proto") else: side = -1.0 if name == "hand_l" else 1.0 hs = ba._hands_and_shoes(mats, spec) @@ -151,7 +233,7 @@ def extract_part(name, variant, out): and o.name == "npc_base"), None) if mesh is None: raise SystemExit(f"no npc_base mesh in {src}") - group = ("Head" if name in ("head", "face") + group = ("Head" if name in ("head", "face", "head_proto") else f"Hand{'L' if name == 'hand_l' else 'R'}") return _extract_group(mesh, group, out)