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
|
### 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
|
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,
|
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.
|
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
|
- [ ] 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.
|
||||||
|
|||||||
@@ -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
|
committed. No new committed `.blend` source tree is added without a docs/ADR decision — the
|
||||||
frozen `npc_base.blend` stays the only tracked `.blend`.
|
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
|
## 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),
|
||||||
|
|||||||
+84
-2
@@ -35,6 +35,7 @@ absolute and derived from this file's location.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import math
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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
|
# 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.
|
# 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")
|
_HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip")
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +93,85 @@ def _export_obj(obj, path):
|
|||||||
return 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):
|
def build_part(name, variant, out):
|
||||||
"""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)
|
||||||
@@ -101,6 +181,8 @@ def build_part(name, variant, out):
|
|||||||
ba._shape_head_mesh(obj)
|
ba._shape_head_mesh(obj)
|
||||||
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":
|
||||||
|
obj = ba.join(_head_proto(mats, spec), "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)
|
||||||
@@ -151,7 +233,7 @@ def extract_part(name, variant, out):
|
|||||||
and o.name == "npc_base"), None)
|
and o.name == "npc_base"), None)
|
||||||
if mesh is None:
|
if mesh is None:
|
||||||
raise SystemExit(f"no npc_base mesh in {src}")
|
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'}")
|
else f"Hand{'L' if name == 'hand_l' else 'R'}")
|
||||||
return _extract_group(mesh, group, out)
|
return _extract_group(mesh, group, out)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user