# 3D model review (2026-11-03) Status: **baseline measured; scorecard open.** This is the working document for the unversioned `3D model re-evaluation` track in [`TODO.md`](../TODO.md). It records what was measured, what the Blender skill guidance says the targets are, the ranked candidate gaps, and the decisions that still need the project owner. Nothing here re-opens the accepted 0.9.4 limits by itself. Related documents: [`npc-visual-review.md`](npc-visual-review.md) (accepted 0.9.4 limits and before/after sheets), [`npc-visual-review.md`](npc-visual-review.md#proportion-audit) (proportion guide), [`blender-assets.md`](blender-assets.md) (asset contract), [`asset-tooling.md`](asset-tooling.md) (commands). ## Method and skill routing The review is screenshot- and measurement-backed, following `qa-review` (screenshot set, defect severity, explicit verdict) rather than an opinion pass: | Family | Skills loaded | Criteria taken from them | | --- | --- | --- | | Routing and pipeline | `blender-director` | Plan → blockout → detail → validate → export; MCP steps translated to headless flatpak runs | | Characters | `character-artist`, `retopology`, `rigging` | Eye/mouth/jaw/shoulder loops, quad-dominant deformation surfaces, 3-loop joints, no triangles on primary deformers | | UVs and textures | `uv-workflow`, `texture-workflow`, `materials` | Texel-density tiers (hero 512–1024, standard 256–512, background 128–256 px/m), consistent density, no overlapping UVs | | Budgets and structure | `asset-optimization`, `lod-pipeline` | Triangle/vertex budgets, naming, LOD tiers | | Props, gear, foliage | `prop-artist`, `hard-surface`, `vegetation-artist` | Silhouette read, bevel/chamfer language, collision proxies, carded foliage | | Look and verdict | `lookdev`, `qa-review` | Day/night beauty captures, ship / ship-with-notes / no-ship verdict | Project contract and gates stay authoritative; see `AGENTS.md` §Local Blender skills. Blender MCP is optional interactive tooling (see [`asset-tooling.md#optional-interactive-blender-mcp`](asset-tooling.md#optional-interactive-blender-mcp)); this review's evidence came from the headless `tools/blender_assets.py` / `tools/npc_pipeline.py` pipelines plus the Godot capture harnesses, so it remains reproducible without MCP. ## What was captured and measured (2026-11-03) | Evidence | Command | Result | | --- | --- | --- | | Contract audit | `python3 tools/blender_assets.py audit --json` | 46/46 assets clean; library 59,012 tris | | Imported-GLB inspection | `blender_assets.py inspect` (all 46) | All clean; transforms applied, helpers ignored | | Gear fit | `python3 tools/blender_assets.py fit --json` | Head width 0.315 m on all eight; clothing radius 0.190–0.260 m | | Proportions | `python3 tools/npc_proportions.py report --json` | All eight inside the stylized guide | | Texel density | `python3 tools/blender_assets.py texels --json` | New measurement, table below | | Neutral baseline | `capture_npc_baseline.gd` → `tmp/model-baseline` (48 frames) + `npc_baseline.py sheet` | 48 frames, none blank; sheet in `tmp/` only | | Final context | `capture_npc_review.gd` → `tmp/model-review` (96 frames) | 96 frames, none blank; raw frames stay local | | Performance | `smoke_npc_budget.gd` | 24 avatars, 168 meshes, 152,464 tris, 6.94 ms of 50 ms | Raw captures and JSON stay in the gitignored `tmp/`; committed review images remain the `docs/blender_previews/sheet_*.jpg` set from 0.9.4/0.9.5 until a fix actually lands. ## Character measurements Contract numbers from `audit` + `inspect`; density from `texels` (256² slot maps, authored and with the runtime 4× fabric repeat). | Character | Tris / verts | Height / feet | Materials | Authored td (px/m) | Fabric effective (px/m) | Verdict | | --- | --- | --- | --- | --- | --- | --- | | `npc_casual` | 5,038 / 4,756 | 1.824 / +0.000 | 7 | 106.8–122.2 | 429–440 | Contract pass; face prototype | | `npc_jacket` | 5,428 / 5,071 | 1.824 / +0.000 | 7 | 100.3–113.7 | 409–413 | Contract pass; face prototype | | `npc_coat` | 6,030 / 5,802 | 1.824 / +0.000 | 7 | 75.3–81.4 | 307–310 | Contract pass; face prototype | | `npc_vest` | 5,536 / 5,375 | 1.819 / +0.000 | 9 | 85.5–99.3 | 362–366 | Contract pass; face prototype | | `npc_dress` | 5,456 / 5,128 | 1.825 / +0.000 | 7 | 81.9–89.0 | 338–339 | Contract pass; face prototype | | `npc_hoodie` | 5,860 / 5,492 | 1.856 / +0.000 | 7 | 89.5–98.6 | 359–364 | Contract pass; face prototype | | `npc_firefighter` | 5,900 / 5,902 | 1.819 / +0.000 | 9 | 64.0–75.1 | 287–290 | Contract pass; face prototype (helmet shadow hides the lower lip) | | `npc_guard` | 5,602 / 5,684 | 1.819 / +0.000 | 11 | 90.0–100.8 | 374–374 | Contract pass; face prototype | | `npc_crowd_a` | 5,200 / 4,734 | 1.825 / +0.000 | 7 | 107–120 | 431–435 | Contract pass; crowd prototype | | `npc_crowd_b` | 4,734 / 4,353 | 1.809 / +0.000 | 7 | 107–118 | 434–439 | Contract pass; crowd prototype | | `npc_crowd_c` | 5,674 / 5,440 | 1.824 / +0.000 | 7 | 76–83 | 317 | Contract pass; crowd prototype | | `npc_crowd_d` | 5,814 / 5,300 | 1.856 / +0.000 | 7 | 92–106 | 377–381 | Contract pass; crowd prototype | | `npc_base` (fallback) | 1,204 / 2,488 | 1.836 / +0.000 | 5 | 70.1–72.9 | 4× on fabric | Contract pass; compact fallback under the 16,000/16,000 cap | Blender-side `inspect` counts can differ by ±1 vertex from the glTF accessor count in `audit` (normal-split rounding); both are recorded and neither changes a verdict. Readings: - Every character is inside 16,000/16,000 with the farthest well below the new cap and 5,494 verts (69%), so there is headroom for targeted topology detail without a cap change. - Surface area tracks clothing volume: the firefighter coat is the largest body (4.28 m²) and has the lowest authored density; `npc_casual` is the smallest (2.44 m²) and the highest. - **Authored density (64–122 px/m) sits below the skill's background tier (128–256 px/m).** The runtime tint controls most of the look: `skin`, `hair`, `accessory` and the fixed detail slots are neutral maps tinted at runtime, not painted skin, so density mostly controls subtle weave/noise. Fabric is effective 287–440 px/m (standard tier) because of the 4× repeat. This is a recorded note, not a defect, until a portrait shows texture starvation. - Material counts 7–11 per character are fixed detail slots (eyes, rubber, hivis, reflective, leather, metal), not extra tint slots. ## Baseline inventory (2026-11-04) Recorded for the Blender NPC model overhaul track: the exact starting point before the local part pipeline is built. `npc_base.blend` is the frozen fallback; the 12 characters are assembled procedurally by `tools/blender_assets.py`; the MakeHuman prototypes live under `tmp/npc-research/` and are not the shipped path. ### `assets/models/npc/npc_base.blend` (frozen fallback) - Mesh `npc_base`: **652 verts / 642 tris**, five material slots in order `pants, cloth, skin, hair, accessory`. - Armature: **14 bones** — `Hips, Spine, Neck, Head, ShoulderL/R, ElbowL/R, HandL/R, HipL/R, KneeL/R` (the required 13 core bones plus the `Hips` root). - It is the compact fallback, not the prototype host; the overhaul must not absorb prototype detail into it unless a separate contract decision approves it. ### 12 generated character GLBs `audit` counts (glTF accessor; Blender `inspect` can differ ±1 on normal-split rounding): | Character | Tris | Verts | | Character | Tris | Verts | | --- | ---: | ---: | --- | --- | ---: | ---: | | `npc_casual` | 5,034 | 4,607 | | `npc_firefighter` | 5,888 | 5,785 | | `npc_jacket` | 5,432 | 5,225 | | `npc_guard` | 5,670 | 5,671 | | `npc_coat` | 6,046 | 5,645 | | `npc_crowd_a` | 5,284 | 4,908 | | `npc_vest` | 5,532 | 5,195 | | `npc_crowd_b` | 4,812 | 4,550 | | `npc_dress` | 5,540 | 5,073 | | `npc_crowd_c` | 5,690 | 5,510 | | `npc_hoodie` | 5,864 | 5,325 | | `npc_crowd_d` | 5,810 | 5,444 | All well under the 16,000/16,000 cap; the largest is `npc_coat` at 38% of the tri budget, so head/face/hand detail has ample headroom. ### Procedural builders (current geometry locations) Coordinates are Blender metres, Z-up; face front is −Y / Godot +Z; `s` = ±1 side, `w` = build width scale. - **Head** — `_face` + `_shape_head_mesh`: `sphere(Head, 0.155, (0, 0, 1.635), seg=24, rings=16, scale=(0.92, 1.0, 1.12))`. Forward-only sculpt (brow at z 1.695, cheek at z 1.612, jaw/chin at z 1.565); recessed creases rejected because they bake as dark AO patches. - **Eyes** — sclera `sphere(0.019, (0.052s, −0.134, 1.664), scale=(1.25, 0.62, 0.85))`; iris `sphere(0.0105, (0.052s, −0.141, 1.664), scale=(1.0, 0.55, 1.0))`; upper lid at z +0.014, lower lid at z −0.012; brow box at (0.052s, −0.143, 1.699). - **Nose** — bridge `cyl(0.016→0.022, (0, −0.145, 1.652))`, tip `sphere(0.022, (0, −0.162, 1.615))`, wings `sphere(0.012, (0.016s, −0.160, 1.606))`. - **Mouth** — slot `sphere(0.028, (0, −0.158, 1.583), scale=(1.0, 0.28, 0.12))`, lower lip `sphere(0.012, (0, −0.156, 1.578))`. - **Ears** — `sphere(0.036, (0.143s, 0.008, 1.63), scale=(0.42, 1.0, 1.35))` + lobe + inner concha. - **Hands** — `_hands_and_shoes`: palm `sphere(0.046, (0.275·s·w, −0.012, 0.762), scale=(0.80, 0.62, 0.88))`; four fingers base (…, 0.778) → tips z 0.701–0.714; thumb (…, 0.778 → 0.760 → 0.738). No finger bones; open palm + `HAND_ITEM_FORWARD` only. ### UV layout - Five tint slots (`skin, hair, accessory, cloth, pants`) at 256² each; fixed detail slots (`eye_white`, rubber, hivis, reflective, leather, metal) are the extra material slots. - Authored texel density 64–122 px/m (below the background tier 128–256), fabric effective 287–440 px/m via the 4× repeat; runtime tint carries the look. This is a recorded note until a portrait shows texture starvation. ### MakeHuman prototypes (not shipped; `tmp/npc-research/makehuman/`) | File | Verts / tris | Bones | Slots | Note | | --- | ---: | ---: | --- | --- | | `makehuman_body.glb` | 19,320 / 6,444 | 0 | `skin_proto` | CC0 intake, unskinned, 1.82 m grounded | | `makehuman_retargeted.glb` | 7,062 / 6,444 | 14 | 5 tint | arm-down + contract rig, deltoid warp remains | | `makehuman_final.glb` | 6,651 / 6,407 | 14 | 8 (incl. `skin.001`, `eye_white`, `rubber`) | project head/hands/shoes swapped; duplicate `skin.001` | The "Icosphere" the inspector lists alongside these GLBs is the glTF importer's bone-display helper (a `pose_bone.custom_shape`), not part of the asset; `inspect_glb` already excludes it from measurements. The real cleanup for `makehuman_final.glb` is the duplicate `skin.001` material and the manual deltoid retopology. ## Overhaul acceptance criteria (measurable) Defined 2026-11-04 for the local part pipeline. A prototype must pass every applicable item before it is rolled out to the other variants; the bar is the stylized low-poly target, not photorealism. ### Face (one-variant prototype first) Capture `front` (yaw 0°), `three_quarter` (yaw 35°) and `profile` (yaw 90°) at **1.5 m** and **1.85 m** (32° FOV), matching `capture_npc_baseline.gd` (a new 1.5 m portrait capture is added for this). Pass conditions: - **Eyes** — sclera + iris + both lids distinct at 1.85 m; at 1.5 m the iris is a readable dark dot against the white sclera, not a floating oval; no socket ring or lid seam. - **Mouth** — the mouth line and lower-lip volume read at both distances; no moustache-like shadow from a floating dark slot. - **Nose** — bridge, tip and ala planes read in three-quarter and profile; no nose-wing seam or inverted normal at the base. - **Ears** — concha depth + lobe read in profile; not a flat disc. - **Jaw/cheek/chin** — front and profile show a jaw transition and cheek/chin planes; no faceted plane seams, inverted normals or z-fighting. - **Silhouette** — front/three-quarter/profile agree; face direction is unambiguous. - **AO** — the 256² bake stays clean: no recessed crease baking into a dark patch. ### Hands (one-variant prototype first) Capture an **open palm** and a **relaxed grip** at **0.8 m** (`--view=hand` plus a new grip capture). Pass conditions: - **Open palm** — four fingers and a thumb read as a hand, not a mitten; at least two of the four finger gaps are readable. - **Grip** — the palm plus curled fingers silhouette reads; no finger-through-palm clipping. - **Gear contact** — phone, cup, clipboard, camera, flashlight, wand and radio contact the palm at 0.8 m with `HAND_ITEM_FORWARD` unchanged; no floating or hand-through-item clipping. ### Contract (both, every prototype) - 16,000 triangles / 16,000 glTF vertices per character; gear stays at 1,500. - Optional extra bones are allowed; the required 13 core bones and their rest positions must not move; the `npc_base` mesh node name and the five tint slots stay unchanged. ### Measurement gates `blender_assets.py inspect`/`audit`/`fit`, `npc_proportions.py`, the 24-NPC budget, Godot + Python tests, the 48-frame baseline and the 96-frame pose/context review all stay authoritative; the new face/hands captures are the visual bar for this track. ### Working-set layout Part sources and preview GLBs live under `tmp/npc-parts//` (gitignored by the root `tmp/` rule); only the assembled character GLBs, AO/maps, the manifest and the review sheets are 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`. Iteration log (vision-judged, profile view): 1. Initial displacement too subtle — read as a "ball with props". 2. Nose + ear visible in profile but the cranium still read as a "ball with a nose". 3. Added face-plane flatten + jaw/chin taper; taper read, but "~90% sphere". 4. Stronger flatten + nose + chin; nose now reads, jawline still dissolves into the skull. 5. Fixed an ear-displacement sign bug (the ± side loop cancelled the helix); the ear now protrudes (x ±0.155 vs the ±0.139 shell) but reads as a soft blob. Current state (prototype built, ~3/5 vision read): the nose, mouth notch, chin bump and ear bulge all read; the centre-profile measurement confirms the relief is objective — nose tip −0.192, brow −0.150, chin −0.149, jaw step −0.11, forehead −0.126→−0.078. The remaining polish (a flatter forehead, a defined ear helix/lobe, a jawline that steps to the neck) is folded into the §3.2 variant comparison; the neck is part of `_skin_body`, so the standalone head always lacks it. ### AI-driven head rework (2026-11-04) The head was reworked against an AI-generated target concept (front + profile, saved under `tmp/npc-parts/head_concept_*.png`) and judged with `describe_image.py` across several iterations. The displacement-on-sphere approach oscillates between "decorated egg" (too subtle) and "spiky wedges" (too aggressive) and settles at ~3/5 — the coarse s48 sphere cannot resolve the nose/lip/chin relief cleanly. The judge then compared the displacement head against the **decimated MakeHuman head** (extracted from `makehuman_retargeted.glb`, 2,009 verts / 1,573 tris, saved as `tmp/npc-parts/makehuman_head.glb`) and returned a decisive result: **ship the MakeHuman head** — it keeps the nose/chin/jaw landmarks at a fraction of the cost, while the procedural head's "clean ellipse outline instantly screams procedural sphere". **Conclusion:** the next head asset is the MakeHuman head shape (1,573 tris) plus the project's separate eye/mouth/brow pieces for readability, not the displacement shell. The displacement `_head_proto` stays local as a rejected variant. ### Head subdivision comparison (2026-11-04) Three `head_proto` variants at UV-sphere segments 24/48/64 were rendered at 1.85 m and 1.5 m in front/three-quarter/profile and judged side by side: | Variant | Verts / tris | 1.85 m | 1.5 m | Verdict | | --- | ---: | --- | --- | --- | | s24 | 670 / 1,168 | lumpy, nose/lips melt into the chin | jagged silhouette, no mouth | reject | | s48 | 1,558 / 2,944 | eyes/brows/nose/lips read cleanly | features resolve cleanly | **pick** | | s64 | 2,470 / 4,768 | visually identical to s48 | indistinguishable from s48 | reject (wasted verts) | The chosen s48 is the `head_proto` default; the rejected s24/s64 stay local under `tmp/npc-parts/`. A before/after sign-off sheet (current `_face` vs s48, 1.85 m front/three-quarter/profile) is [`blender_previews/sheet_head_proto_before_after.jpg`](blender_previews/sheet_head_proto_before_after.jpg). ## Scorecard skeleton Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail), **P1** (major: silhouette/readability), **P2** (minor), **accept** (recorded limit). `—` means not yet reviewed; this document is the place to fill it. ### Characters | Character | Silhouette | Face @1.85 m | Hands/grip | Weights/pose | Cloth/hair | LOD | Verdict | | --- | --- | --- | --- | --- | --- | --- | --- | | *(pose captured 2026-11-04: see "Pose evidence" below)* | | | | | | | | | `npc_casual` | accept (0.9.4) | accept (face prototype) | accept (open palm) | fixed (wrist/hand + hip/knee) | accept | accept (two-quad impostor) | pending owner | | `npc_jacket` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_coat` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (hem 0.76 stride) | accept | pending owner | | `npc_vest` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_dress` | accept; performer props dominate | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_hoodie` | accept (hooded criterion) | accept (lower face only) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_firefighter` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_guard` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner | | `npc_crowd_a` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | crowd prototype | | `npc_crowd_b` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (beard) | accept | crowd prototype | | `npc_crowd_c` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (coat hem) | accept | crowd prototype | | `npc_crowd_d` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | crowd prototype | ### Gear (18) | Gear | Attach | Tris | Verdict | | --- | --- | --- | --- | | `cap` | head | 328 | — (fit measured, visual pending) | | `hood` | head | 804 | — | | `sunglasses` | head | 164 | accept (0.9.4 clearance) | | `firefighter_helmet` | head | 672 | accept (0.9.4 + 3 cm runtime offset) | | `backpack` | back | 380 | accept (0.9.4 strap refit) | | `shoulder_bag` | back | 188 | — | | `scba_tank` | back | 362 | — | | `lanyard` | chest | 100 | accept (0.9.4 + 4 cm runtime offset) | | `phone` | hand | 96 | accept (forward offset) | | `cup` | hand | 188 | accept | | `clipboard` | hand | 84 | accept | | `umbrella` | hand | 134 | accept | | `camera` | hand | 318 | accept | | `protest_sign` | hand | 92 | accept (performer/protester read) | | `flashlight` | hand | 168 | accept | | `wand` | hand | 244 | accept | | `radio` | hand | 220 | accept | Gear total: 5,002 tris across 18 pieces (all far below the 1,500-per-piece cap). `blender_assets.py fit` readings (2026-11-03): `shoulder_bag` is clear on both `npc_coat` and `npc_dress` (the only back-gear clearance the fit test asserts); `sunglasses` sits inside the head band at 0.64× head width. Three caveats worth recording, none of which changes a verdict: `backpack` (npc_casual) and `scba_tank` (npc_firefighter) report `CLIP` because back gear is measured against the **hip/skirt** radius, which is wider than the upper back where the bags actually sit; `cap` and `firefighter_helmet` report `band=OUTSIDE` because they sit on the crown above the 1.55–1.81 face/head band (expected); and the `lanyard` clip is corrected at runtime by `GEAR_OFFSETS` (+4 cm forward). The unused `beanie` was retired on 2026-11-03 (removed from `GEAR`, its builder, `npc_avatar.gd` and the manifest). ### Props (20 library + 6 generated fallbacks) Library total 13,608 tris; `marquee` (2,804) and `street_tree` (2,592) are the two heaviest and are the first candidates for a `vegetation-artist`/lookdev pass. | Prop | Tris | Verdict | | --- | --- | --- | | `bollard` | 138 | accept (2026-11-03 flared base collar) | | `bench` | 560 | — | | `trash_bin` | 228 | — | | `planter_box` | 1,324 | — | | `newspaper_box` | 300 | accept (2026-11-03 recessed slot + door seam) | | `fire_hydrant` | 610 | accept (2026-11-03 rebuild: bolted flange, bonnet cap nut, larger side + front outlets) | | `street_lamp` | 428 | — | | `street_tree` | 2,592 | P1 candidate (flat-shaded clumps) | | `palm_tree` | 400 | P1 candidate (same foliage language) | | `round_planter` | 1,240 | — | | `a_frame_sign` | 212 | — | | `wall_lantern` | 180 | — | | `traffic_cone` | 210 | — | | `crowd_barrier` | 388 | — | | `stanchion` | 434 | — | | `handrail` | 268 | — | | `security_podium` | 184 | — | | `double_door` | 380 | — | | `marquee` | 2,804 | — (emissive check pending) | | `sedan` | 928 | — | Vision review 2026-11-03 (`tools/describe_image.py` on the prop gallery): `bench` and `trash_bin` are ship-ready (minor chamfer polish only); `bollard` passes but leans on its reflective band (add a base collar); `planter_box`/`round_planter` fail on the foliage — the `_shrub()` clumps read as rocks, not plants (same foliage language gap, gated on the owner); `newspaper_box` is borderline (the slot is a flat decal, wants a recessed inset + door seam); `fire_hydrant` is now rebuilt (see row). No prop floats or sinks; shadow direction is consistent. Generated fallbacks (`assets/models/generated/`): bench, bollard, fire_hydrant, newspaper_box, planter_box, trash_bin. They are only used when the library prop is missing, so they are audited for presence, not re-reviewed for art quality. ### Venues | Venue | Measured (map-budget test) | Status | | --- | --- | --- | | Hollywood Boulevard | 58,884 tris / 409 meshes | shape-language review pending | | The Joke Shop | 26,828 tris / 580 meshes | facade review landed: 6.0 m low hall, white signature band, tied-back curtains, west hotel tower; remaining gap = rounded west corner | | TV Studio | — | shape-language review pending | ## Pose evidence (fixed 2026-11-04) The weights/pose column can now be filled. The root cause was the harness: it never disabled the avatar's `_physics_process`, so `_advance_fsm` + `_move_toward_target` reset `arrived`/velocity every tick and `theater_pose()` reverted to idle. `capture_npc_pose.gd` now calls `set_physics_process(false)`/`set_process(false)` after staging, and the pose sticks (sit vs idle diff 5.63, walk vs idle 2.29, vision-judged as crouched). The earlier "moving the `Rig` child does not render" probe was a red herring: it set the position in `_shoot()` in the same frame as `root.get_texture().get_image()`. `src/test/capture_npc_pose.gd` stages idle / walk-contact / walk-pass / seated for all twelve variants (full-body + shoulder views). | Check | Result | | --- | --- | | Pose reaches proxies | Seated pose logs `rig_y=-0.339`, `leg0=-1.357` | | Pose reaches skeleton bones | `HipL`/`KneeL` bone rotations differ from rest; headless test `test_pose_reaches_the_skeleton_bones` locks it (suite 273/273) | | Idle restores rest | Same test asserts the hip returns to its rest orientation | | Skinning bound | One `Skeleton3D`, mesh bound to it (`same=true`); hiding `npc_base` removes the body | | Bone rotations deform the mesh | Direct writes change rendered pixels: Spine 1,719 / HipL 1,052 / KneeL 810 / ElbowL 745 / Head 671 / ShoulderL 640 sampled pixels | | Bone translation deforms the mesh | A +0.5 m Head offset moves the head in frame | | Standalone window probes render the pose | Three variants (avatar under `root`, under a `Node3D` stage, created mid-`_process`) render the seated crouch (diff ~2,200-2,360 samples) | | Offscreen capture renders the pose | A `SubViewport` + `RenderingServer.force_draw()` probe renders the crouch reliably (diff 602 at +2/+6/+12 frames), including a re-created second avatar | | The harness renders the pose | **No** in its current form: same stage, same pose, rest frames. One ordering (`_frame_view()` immediately before `force_draw()`) produced posed walk frames once, then regressed to rest/garbage on the next run | The rig, the animation math and the offscreen render path are all sound. A 2026-11-03 re-attempt with the recipe (SubViewport + `force_draw()` + `force_update_all_bone_transforms()` + disabled process/physics) still produced byte-identical rest frames, and a decisive probe narrowed the failure further: teleporting the **avatar** changes the render (diff 10), but moving the **`Rig` child** (the GLB model's parent) does not (diff 0). The skinned mesh therefore does not follow its `Rig` Node3D transform, so the rig lowering never reaches the rendered model — the defect is upstream of camera/`force_draw()` ordering. Next attempt must answer why the skinned model ignores the `Rig` node, or stage poses via the known-good `capture_npc_review.gd` fallback. Working recipe (verified by the probes; keep it when rebuilding): 1. Create a `SubViewport` (1280x720, `own_world_3d = true`, `UPDATE_ALWAYS`) in `_initialize`. 2. Build the studio (environment, key/fill light, ground, camera) and one avatar inside it. 3. Wait 2-3 frames so the skinned mesh binds. 4. Apply the pose with `_animate()` ticks, with the avatar's process/physics disabled. 5. Call `RenderingServer.force_draw()`, then `viewport.get_texture().get_image()`. 6. Free the avatar and repeat for the next pose; a re-created avatar also rendered correctly in a probe. ## Ranked candidate gaps Ordered by expected player-visible gain per unit of work. None is approved for fixing yet; the owner review decides. 1. **Face plane structure at portrait distance (P1, all eight).** The neutral sheet shows flat, faceted cheek/brow planes and slit-like eyes at 1.85 m. `character-artist`/`retopology` guidance asks for concentric eye loops and a 3-loop mouth. This is the largest readability gap and also the most expensive (head rebuild + re-UV + re-AO per variant). Needs a targeted single-variant prototype before committing to eight. The eight library characters already carry the 0.9.4 face detail in `tools/blender_assets.py._face()`: sculpted chin plate + cheekbones (`_shape_head_mesh`), socketed sclera + thin upper lid, nose bridge/tip/wings, a rounded mouth and ear concha (the plain sphere head in `tools/npc_pipeline.py` is the frozen `npc_base` fallback only). So this gap is a readability/scale question on the existing sculpt — the head is `seg=18/rings=12` with small offsets — not missing geometry. Concrete next step, if funded: a single-variant `npc_casual` prototype that raises head subdivision and deepens the cheek/brow offsets, then re-bakes AO and captures the portrait before touching the other seven. **Prototype landed 2026-11-03/04:** `npc_casual` now has `seg=24/rings=16` (smoother), a subtle brow ridge, a fuller lower lip and both eyelids (upper + lower); recessed creases (nasolabial, temple, chin cleft) were rejected because they bake as dark AO patches. Vision-reviewed: the new face reads cleaner and more realistic than the old (set-in eyes, brow break, lip volume, no faceting); tris 4,482 → 5,038 (still under 16,000). The other seven variants keep the old face until this prototype is approved. 2. **No pose/weight evidence (unknown).** Idle, walk, sit, block and turn deformation has never been captured as a review set, so shoulder/hip/neck collapse is unmeasured. Cheap to add and it gates any claim that the rig is acceptable. 3. **Foliage language (P1, `street_tree`, `palm_tree`, `planter_box`, `round_planter`).** Flat-shaded clumps against an otherwise readable set, already deferred once. `vegetation-artist` carded clusters would fix it inside the existing triangle budget. 4. **Texel density below the background tier (note).** 64–122 px/m authored vs the skill's 128–256 background tier. Fabric is already standard-tier at runtime. Decide: accept because the maps are neutral tinted detail, or raise only `skin`/`hair` to 512² for portrait distance. 5. **`npc_firefighter` density floor (P2).** 64 px/m with the largest coat area; split the coat UV shell or raise the map size before the next character pass, whichever the density decision says. 6. **Hands at conversation distance (accept, revisit only if face work opens the budget).** Open palm with `HAND_ITEM_FORWARD`; no finger bones exist, so a curl rig is a contract decision, not a modelling tidy-up. 7. **Gear never reviewed as silhouette language (P2).** 18 pieces pass fit/contract but have no prop-artist verdict (the unused `beanie` was retired 2026-11-03). 8. **Venue shape language (P2).** Hollywood and the Joke Shop have budget data but no `environment-artist`/`archviz` review; the TV Studio has no recorded map-budget number in this document yet. ## qa-review sign-off (2026-11-04) All eight library characters **ship with notes**. Face realism landed on all eight (`seg=24/rings=16`, brow ridge, both eyelids, fuller lower lip), hands/grip and cloth/hair are accepted as recorded, gear fit is measured with its caveats, and the props are reviewed (hydrant/bollard/newspaper_box rebuilt, foliage two-toned). The only unresolved column is **weights/pose** (blocked on the capture harness), and the full carded-cluster foliage pass is the one remaining owner-gated P1. No P0 remains. ## Decisions needed from the owner - Accept the measured texel density as a stylized limitation, or fund a skin/hair resolution pass. - Approve or reject a single-variant face/eye-loop prototype (and pick which variant to prototype on) before any eight-character rebuild. - Decide whether the pose/weight capture is required before the crowd-variant roster. - Confirm foliage rebuild priority versus the map track (both touch the same street trees). ## Next actions Tracked in [`TODO.md`](../TODO.md) `3D model re-evaluation`: the remaining `A. Baseline…` item is this owner review; `B`/`C`/`D` carry the per-family work, and `E` adds the pose/weight, collider-coverage and scorecard-coverage gates this document found missing.