Add the connected-shell head prototype (head_proto) with sculpted sockets/nose/lips/ears
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-04 14:23:19 +01:00
1 parent b8d8869e03
commit 50edc47480
3 files changed
+103 -3

No files matched your search

+6 -1
View File
@@ -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.
+13
View File
@@ -210,6 +210,19 @@ Part sources and preview GLBs live under `tmp/npc-parts/<variant>/` (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),
+84 -2
View File
@@ -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)