diff --git a/docs/screens/head-review/README.md b/docs/screens/head-review/README.md new file mode 100644 index 0000000..29d8586 --- /dev/null +++ b/docs/screens/head-review/README.md @@ -0,0 +1,53 @@ +# Head rework — review page (2026-11-04) + +This page is the review checkpoint for the NPC head rework. The goal was to replace the +sphere-and-floating-primitive face with a head that has real nose / chin / jaw landmarks at +portrait distance. + +## What changed + +The original procedural face is a head sphere plus separate nose, mouth, ear and eye primitives. +The new head uses the **decimated MakeHuman head** (CC0, 1,573 tris) as the base shape and keeps +the project's separate eyeballs, brow and mouth pieces on top. An AI judge was used to drive the +decision: it scored the procedural rework at ~3/5 and then picked the MakeHuman head as the +clearer silhouette. + +| | Source | Tris (head) | Read | +| --- | --- | --- | --- | +| **Before** | procedural `_face` (sphere + primitives) | ~1,900 | flat face, slit eyes | +| **After** | MakeHuman head + project eyes/brows/mouth | ~2,180 | nose/chin/jaw landmarks | + +## AI-generated target concept + +The rework was guided by two generated concept frames (front + profile, `bytedance-seed/seedream-4.5`). + +![concept](sheet_concept.jpg) + +## 1.85 m comparison (top = current, bottom = new) + +Left to right: front, three-quarter, profile. + +![1.85 m comparison](sheet_compare_1.85.jpg) + +## New head at 1.5 m (portrait) + +![1.5 m portrait](sheet_head_1.5.jpg) + +## Contract and checks + +- `blender_assets.py inspect` on the assembled `npc_casual` with the new head: **14 bones, + skinned, no issues**. +- Source: `assets/models/blender_src/makehuman_head.glb` (CC0, attributed in `CREDITS.md`). +- Python 167/167, Godot 277/277, `check_docs` OK. + +## Remaining polish (known, not blockers) + +1. The hair cap still reads as a stepped block instead of hugging the skull. +2. The brows weld to the face but can read as a slightly separate bar. +3. The neck underside cap is plain; it is hidden by the body neck in the assembled character. + +## Decision needed + +Approve or reject the MakeHuman-head direction. If approved, the next step is rolling it into the +other 11 variants (one asset family at a time) with a before/after sheet per batch, and re-running +the 24-NPC budget + AO bake. diff --git a/docs/screens/head-review/sheet_compare_1.85.jpg b/docs/screens/head-review/sheet_compare_1.85.jpg new file mode 100644 index 0000000..c7f2504 Binary files /dev/null and b/docs/screens/head-review/sheet_compare_1.85.jpg differ diff --git a/docs/screens/head-review/sheet_concept.jpg b/docs/screens/head-review/sheet_concept.jpg new file mode 100644 index 0000000..112d31f Binary files /dev/null and b/docs/screens/head-review/sheet_concept.jpg differ diff --git a/docs/screens/head-review/sheet_head_1.5.jpg b/docs/screens/head-review/sheet_head_1.5.jpg new file mode 100644 index 0000000..df6c173 Binary files /dev/null and b/docs/screens/head-review/sheet_head_1.5.jpg differ diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 3b6ea35..92610b3 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -1008,7 +1008,28 @@ def _makehuman_head(mats, spec): bmesh.ops.holes_fill(bm, edges=boundary) bm.to_mesh(head.data) bm.free() - parts = [head] + + # Lids and brows weld into the head shell so the face reads as one surface; + # the sclera/iris stay separate eyeballs. + shell_parts = [head] + for s in (-1.0, 1.0): + ex, ez = 0.052 * s, 1.664 + shell_parts.append(sphere("UpperLid", 0.020, (ex, -0.135, ez + 0.014), + skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), + rot=(math.radians(-10), 0, 0))) + shell_parts.append(sphere("LowerLid", 0.017, (ex, -0.135, ez - 0.012), + skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), + rot=(math.radians(6), 0, 0))) + shell_parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.165, 1.705), + hair, rot=(0, 0.14 * s, 0.0))) + shell = join(shell_parts, "HeadShell") + bm = bmesh.new() + bm.from_mesh(shell.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.008) + bm.to_mesh(shell.data) + bm.free() + + parts = [shell] for s in (-1.0, 1.0): # The MakeHuman socket is a deep cavity (floor ≈ −0.105); keep the # sclera recessed so it never breaks the face silhouette. @@ -1017,17 +1038,12 @@ def _makehuman_head(mats, spec): rings=6, scale=(1.25, 0.62, 0.85))) parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, rings=5, scale=(1.0, 0.55, 1.0))) - parts.append(sphere("UpperLid", 0.020, (ex, -0.135, ez + 0.014), - skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), - rot=(math.radians(-10), 0, 0))) - parts.append(sphere("LowerLid", 0.017, (ex, -0.135, ez - 0.012), - skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), - rot=(math.radians(6), 0, 0))) - parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.165, 1.705), - hair, rot=(0, 0.14 * s, 0.0))) # A subtle mouth line on top of the MakeHuman lips for readability. parts.append(sphere("Mouth", 0.028, (0, -0.165, 1.58), acc, seg=8, rings=5, scale=(1.0, 0.28, 0.12))) + # A skull-conforming buzz cap sized for the smaller MakeHuman cranium. + parts.append(_hair_cap("Hair", hair, 1.70, 1.56, radius=0.115, + center=(0, 0.008, 1.66), scale=(0.95, 1.04, 1.05))) return parts