Add the connected-shell head prototype (head_proto) with sculpted sockets/nose/lips/ears
This commit is contained in:
1 parent
b8d8869e03
commit
50edc47480
3 files changed
+103
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user