diff --git a/MEMORY.md b/MEMORY.md index b0daa58..72496a8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -70,6 +70,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o backpack straps read as rigid bars. The tourist prop now hides outside photo/film poses and the backpack straps were refit as flat forward bands (GLB + AO rebuilt); the face remains P1. Before/ after sheet: `sheet_npc_baseline_compare.jpg`. +- **NPC proportion audit (0.9.4, 2026-11-02):** `tools/npc_proportions.py` measures the exported + GLBs (height, skin-head size, upper-chest shoulder silhouette, pants hip width, Spine-to-shoulder + torso, Spine-to-ground leg, shoulder-to-hand arm, shoe foot length). All eight variants pass the + stylized guide in `docs/npc-proportion-audit.md`; head ~0.190 (upper 0.22) and broad-build + shoulders ~0.370/hip ~0.225 are near the upper bounds, not outliers. No skeleton/mesh rescale was + needed; run the report after any body change. - **UI flow smoke (2026-11-02):** `smoke_ui_flows.gd` boots the real scene and exercises inspection allow/deny, normal vs emergency radio, Help filtering, wand allow/deny and review return-to-menu. Keep this smoke in the 0.9.6 @@ -743,7 +749,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. -- Headless suite is **262/262**; Python tooling is **99/99**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **262/262**; Python tooling is **101/101**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. - Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). - GDScript inference trap that broke the main scene: a `var` typed as a base class (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a diff --git a/README.md b/README.md index 7b4b8eb..18d37d6 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Full setup, every run/test/smoke command, and the controls table are in - [`docs/smoke-checklist.md`](docs/smoke-checklist.md) — Manual smoke-test checklist. - [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points. - [`docs/npc-visual-review.md`](docs/npc-visual-review.md) — 0.9.4 NPC baseline, acceptance criteria and ranked visual gaps. +- [`docs/npc-proportion-audit.md`](docs/npc-proportion-audit.md) — NPC proportion measurements against the shared stylized guide. - [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`TODO.md`](TODO.md) — Active tasks plus the 0.x gap analysis and milestone roadmap. diff --git a/TODO.md b/TODO.md index a3e9990..7c10066 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these Baseline: **v0.9.3** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 262 headless + 99 Python +escalation/access/radio loop, shift review, the Blender asset library, 262 headless + 101 Python tests, a 21/21 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong release-candidate foundation, not yet a 0.10.0 release candidate. @@ -82,8 +82,10 @@ and equipment fit. ### Body proportions and silhouette -- [ ] Audit all variants against a shared human proportion guide (head height, shoulder width, +- [x] Audit all variants against a shared human proportion guide (head height, shoulder width, torso length, hip width, limb length, foot length) and correct the worst outliers. + (`tools/npc_proportions.py` + `docs/npc-proportion-audit.md`: all eight pass; no skeleton or + mesh rescue was needed. Head and broad-build silhouettes are nearest their upper bounds.) - [ ] Improve neck, shoulder, torso and hip transitions so the silhouette reads as one body rather than stacked primitives; preserve the existing broad/slim/average build variations. - [ ] Add subtle body-shape variation between variants beyond uniform scaling (shoulder slope, diff --git a/docs/README.md b/docs/README.md index 100392c..e0649b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ This folder holds the project documentation, visual references and generated QA - [`getting-started.md`](getting-started.md) — setup, run/test commands and controls - [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates - [`npc-visual-review.md`](npc-visual-review.md) — NPC visual baseline, acceptance criteria and ranked gaps +- [`npc-proportion-audit.md`](npc-proportion-audit.md) — NPC proportion ratios and stylized guide results - [`development-status.md`](development-status.md) — implemented features and current gaps - [`smoke-checklist.md`](smoke-checklist.md) — manual verification - [`ui-copy.md`](ui-copy.md) — screen, copy and button inventory diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index 091eca8..e83f9f0 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -213,6 +213,11 @@ and firefighter helmet so the badge clears the jacket and the brim does not sit over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear contract. +`tools/npc_proportions.py` checks the eight character GLBs against the shared +height/head/shoulder/hip/torso/leg/arm/foot guide; the current results are in +[`npc-proportion-audit.md`](npc-proportion-audit.md). It reads the exported GLBs +and does not require Blender. + `capture_npc_baseline.gd` is the 0.9.4 neutral baseline: eight characters × front/three-quarter/profile/back/full-body/crowd = 48 frames with a fixed camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in diff --git a/docs/development-status.md b/docs/development-status.md index 278e56d..5ce3f42 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -39,6 +39,7 @@ Implemented so far: - Aim-free selection pass (v0.3.0 work, [ADR-0011](adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work - Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing. The wand panel lists the guest's carried items, and multiline HUD/wand strings render real line breaks. +- NPC proportion audit (0.9.4 work): `tools/npc_proportions.py` measures all eight GLBs against a stylized height/head/shoulder/hip/torso/leg/arm/foot guide. All variants pass; the head and broad-build silhouettes are the nearest upper bounds. Results are in [`npc-proportion-audit.md`](npc-proportion-audit.md). - NPC P1 portrait fixes (0.9.4 work): tourists now hide their photo-pose camera bag outside `photo`/`film` poses, removing the duplicate-backpack silhouette; the backpack gear was rebuilt with flattened, forward chest straps and a fresh AO map. The before/after sheet is [`blender_previews/sheet_npc_baseline_compare-p1.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg); the face and remaining clothing/hand work stay open. - NPC visual baseline (0.9.4 work): `src/test/capture_npc_baseline.gd` captures all eight library characters from front, three-quarter, profile, back, full-body and crowd views in a neutral studio (48 frames). The criteria, measurements and ranked gaps are in [`npc-visual-review.md`](npc-visual-review.md); the committed sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). No model changes were made in this baseline step. - UI-state capture pass (0.9.3 work): `src/test/capture_ui_states.gd` adds committed captures 14–21 for the start menu, pause, settings, shift review, emergency radio, action wheel, crowd panic and responder arrival. Every one of the 21 catalogue entries now has a current-build capture; `tools/check_screenshots.sh` reports 21 good / 0 bad and the automatic judge reports 21/21 match, 0 diff. Crowd panic and responder arrival are staged presentation frames, not gameplay changes. diff --git a/docs/npc-proportion-audit.md b/docs/npc-proportion-audit.md new file mode 100644 index 0000000..36e68c0 --- /dev/null +++ b/docs/npc-proportion-audit.md @@ -0,0 +1,79 @@ +# NPC proportion audit + +Status: audited 2026-11-02. All eight library variants are inside the stylized +proportion guide; no geometry correction was required in this pass. The report +is repeatable from the exported GLBs: + +```bash +python3 tools/npc_proportions.py report +python3 tools/npc_proportions.py report --json +``` + +## Method + +`tools/npc_proportions.py` reads the GLB meshes and rest-pose joint hierarchy: + +- height from the full mesh bounds; +- head height, width and aspect from `Head`/`skin` vertices (hair excluded so + long hairstyles do not change the head measurement); +- shoulder silhouette from vertices in the upper-chest band around the + shoulder joint; +- hip width from `HipL`/`HipR` pants vertices; +- torso length from the Spine joint to the shoulder joint; +- leg length from the Spine joint to the lowest vertex; +- arm length from the shoulder joint to the Hand joint; +- foot length from the forward extent of the KneeL/KneeR shoe vertices. + +All ratios are divided by total standing height except `head_aspect` +(width / head height). + +## Guide + +These are stylized-but-credible bands for the game's low-poly adults, not +photoreal anatomy. The broad build is allowed a larger shoulder/hip silhouette +than the slim build. + +| Ratio | Target | Notes | +| --- | ---: | --- | +| `head_height` | 0.16–0.22 | deliberately larger than a realistic 0.13–0.15 | +| `head_aspect` | 0.70–1.00 | width / height; rounded heads are acceptable | +| `shoulder_width` | 0.28–0.38 | silhouette includes sleeves and build scaling | +| `hip_width` | 0.17–0.23 | pants-only width, not skirt/coat flare | +| `torso_length` | 0.28–0.38 | Spine joint to shoulder | +| `leg_length` | 0.40–0.48 | Spine joint to ground | +| `arm_length` | 0.28–0.38 | shoulder to hand | +| `foot_length` | 0.12–0.18 | shoe forward extent | + +## Result + +| Character | Height | Head | Aspect | Shoulder | Hip | Torso | Leg | Arm | Foot | Result | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `npc_casual` | 1.824 m | 0.190 | 0.907 | 0.320 | 0.209 | 0.307 | 0.449 | 0.329 | 0.144 | PASS | +| `npc_jacket` | 1.824 m | 0.190 | 0.907 | 0.325 | 0.209 | 0.307 | 0.449 | 0.329 | 0.144 | PASS | +| `npc_coat` | 1.824 m | 0.190 | 0.907 | 0.329 | 0.209 | 0.307 | 0.449 | 0.329 | 0.144 | PASS | +| `npc_vest` | 1.819 m | 0.191 | 0.907 | 0.321 | 0.210 | 0.308 | 0.451 | 0.330 | 0.144 | PASS | +| `npc_dress` | 1.825 m | 0.190 | 0.907 | 0.287 | 0.191 | 0.307 | 0.449 | 0.329 | 0.144 | PASS | +| `npc_hoodie` | 1.856 m | 0.187 | 0.907 | 0.320 | 0.205 | 0.302 | 0.442 | 0.323 | 0.141 | PASS | +| `npc_firefighter` | 1.819 m | 0.191 | 0.907 | 0.356 | 0.225 | 0.308 | 0.451 | 0.330 | 0.148 | PASS | +| `npc_guard` | 1.819 m | 0.191 | 0.907 | 0.370 | 0.219 | 0.308 | 0.451 | 0.330 | 0.144 | PASS | + +No variant is outside a band, so the body pass does not need a global skeleton +or mesh rescale. The values closest to a boundary are: + +- **head height 0.190** against the 0.22 upper bound, and **head aspect 0.907** + against 1.00 — the head is deliberately large. Any head-size change must be + coordinated with the face/headgear pass and its gear-fit measurements. +- **guard shoulder 0.370** and **firefighter hip 0.225** against the broad-build + upper bounds — these are the intended uniform/turnout silhouettes, not + outliers. +- **leg length 0.449** and **torso 0.307** sit inside the guide for all variants; + the earlier short-leg impression came from the hip joint position, not the + visible pelvis line. + +## Follow-up + +The proportion audit does not replace the visual criteria in +[`npc-visual-review.md`](npc-visual-review.md): neck/shoulder/hip transitions, +face depth, hand grips, clothing hems and shoes still need the appearance pass. +Re-run the audit after any body mesh or skeleton change and record the new +ratios here. diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md index 7dbd5eb..36c18d0 100644 --- a/docs/npc-visual-review.md +++ b/docs/npc-visual-review.md @@ -84,6 +84,14 @@ silhouette (normal for a backpack, suspicious when it is a strap). Runtime `NpcAvatar.GEAR_OFFSETS` is applied after attachment and is not part of the GLB measurement. +## Proportion audit + +The separate [`npc-proportion-audit.md`](npc-proportion-audit.md) measures +height, head size, shoulder/hip width, torso, leg, arm and foot ratios for every +variant with `tools/npc_proportions.py`. All eight pass the stylized guide; the +head and broad-build shoulders sit nearest their upper bounds. No skeleton or +mesh proportion change was needed in this pass. + ## Baseline measurements From `python3 tools/blender_assets.py fit` and the manifest: diff --git a/tools/npc_proportions.py b/tools/npc_proportions.py new file mode 100644 index 0000000..25e6de6 --- /dev/null +++ b/tools/npc_proportions.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Audit the procedural NPC library against a shared proportion guide. + +The measurements come from the exported GLBs, not from authored node values: +head bounds use Head/skin vertices, shoulder silhouette uses the upper-chest +band, hip width uses pants vertices and limb lengths use the rest-pose bones. +This keeps the report valid for every character variant even when clothing or +hair changes the overall bounds. + +Run outside Blender: + + python3 tools/npc_proportions.py report + python3 tools/npc_proportions.py report --json +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path + +from blender_assets import _accessor, _read_glb, asset_path + +ROOT = Path(__file__).resolve().parent.parent +# Ratios are measured against total standing height. These are stylized-but- +# credible bands, not photoreal anatomy: the character is deliberately a low- +# poly adult with a somewhat larger head and a broad build option. +GUIDE = { + "head_height": (0.16, 0.22), + "head_aspect": (0.70, 1.00), + "shoulder_width": (0.28, 0.38), + "hip_width": (0.17, 0.23), + "torso_length": (0.28, 0.38), + "leg_length": (0.40, 0.48), + "arm_length": (0.28, 0.38), + "foot_length": (0.12, 0.18), +} + + +def _qmul(a, b): + ax, ay, az, aw = a + bx, by, bz, bw = b + return (aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz) + + +def _qrot(q, v): + x, y, z, w = q + tx = 2.0 * (y * v[2] - z * v[1]) + ty = 2.0 * (z * v[0] - x * v[2]) + tz = 2.0 * (x * v[1] - y * v[0]) + return (v[0] + w * tx + (y * tz - z * ty), + v[1] + w * ty + (z * tx - x * tz), + v[2] + w * tz + (x * ty - y * tx)) + + +def joint_positions(gltf): + """World rest-pose joint centres from the glTF node hierarchy.""" + nodes = gltf["nodes"] + parent = {} + for index, node in enumerate(nodes): + for child in node.get("children", []): + parent[child] = index + roots = [i for i in range(len(nodes)) if i not in parent] + out = {} + + def walk(index, parent_t=(0.0, 0.0, 0.0), parent_q=(0.0, 0.0, 0.0, 1.0)): + node = nodes[index] + translation = node.get("translation", [0.0, 0.0, 0.0]) + rotation = node.get("rotation", [0.0, 0.0, 0.0, 1.0]) + rotated = _qrot(parent_q, translation) + world = tuple(parent_t[i] + rotated[i] for i in range(3)) + out[node.get("name", "")] = world + child_q = _qmul(parent_q, rotation) + for child in node.get("children", []): + walk(child, world, child_q) + + for root in roots: + if nodes[root].get("name") == "Armature": + walk(root) + return out + + +def mesh_vertices(gltf, binary): + """Return (position, strongest bone, material) for every GLB vertex.""" + nodes = gltf["nodes"] + joints = [nodes[j].get("name", "") for j in gltf["skins"][0]["joints"]] + materials = [m.get("name", "") for m in gltf.get("materials", [])] + out = [] + for mesh in gltf.get("meshes", []): + for prim in mesh.get("primitives", []): + attrs = prim.get("attributes", {}) + if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs: + continue + positions = _accessor(gltf, binary, attrs["POSITION"]) + joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"]) + weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"]) + mat_index = prim.get("material") + material = materials[mat_index] if mat_index is not None and mat_index < len(materials) else "" + for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows): + strongest = max(range(4), key=lambda i: weight_row[i]) + index = joint_row[strongest] + bone = joints[index] if index < len(joints) else "" + out.append((pos, bone, material)) + return out + + +def _span(values): + return max(values) - min(values) if values else 0.0 + + +def measure(name): + _magic, _version, _length, _size, gltf, binary = _read_glb(asset_path("npc", name)) + joints = joint_positions(gltf) + verts = mesh_vertices(gltf, binary) + ys = [p[1] for p, _bone, _material in verts] + height = _span(ys) + head = [p for p, bone, material in verts if bone == "Head" and material == "skin"] + shoulder_y = joints["ShoulderL"][1] + shoulder = [p for p, _bone, _material in verts + if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06] + hip = [p for p, bone, material in verts + if bone in ("HipL", "HipR") and material == "pants"] + feet = [p for p, bone, _material in verts + if bone in ("KneeL", "KneeR") and p[1] < 0.2] + head_h = _span([p[1] for p in head]) + head_w = _span([p[0] for p in head]) + shoulder_w = _span([p[0] for p in shoulder]) + hip_w = _span([p[0] for p in hip]) + torso = shoulder_y - joints["Spine"][1] + leg = joints["Spine"][1] - min(ys) + arm = shoulder_y - joints["HandL"][1] + foot = _span([p[2] for p in feet]) + values = { + "height": height, + "head_height": head_h, + "head_width": head_w, + "shoulder_width": shoulder_w, + "hip_width": hip_w, + "torso_length": torso, + "leg_length": leg, + "arm_length": arm, + "foot_length": foot, + } + ratios = { + "head_height": head_h / height, + "head_aspect": head_w / head_h, + "shoulder_width": shoulder_w / height, + "hip_width": hip_w / height, + "torso_length": torso / height, + "leg_length": leg / height, + "arm_length": arm / height, + "foot_length": foot / height, + } + checks = {} + for key, (low, high) in GUIDE.items(): + value = ratios[key] + checks[key] = { + "value": round(value, 3), + "range": [low, high], + "status": "ok" if low <= value <= high else "outside", + } + return { + "character": name, + "measurements": {key: round(value, 3) for key, value in values.items()}, + "ratios": {key: round(value, 3) for key, value in ratios.items()}, + "checks": checks, + } + + +def report(): + from blender_assets import CHARACTERS + return [measure(name) for name in CHARACTERS] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + show = sub.add_parser("report") + show.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + if args.command != "report": + return 1 + rows = report() + if args.json: + print(json.dumps(rows, indent=2)) + return 0 + for row in rows: + print(f"{row['character']:16s} height={row['measurements']['height']:.3f} m") + for key, check in row["checks"].items(): + print(f" {key:18s} {check['value']:.3f} " + f"target {check['range'][0]:.2f}-{check['range'][1]:.2f} " + f"{check['status']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_npc_proportions.py b/tools/test_npc_proportions.py new file mode 100644 index 0000000..5f645bf --- /dev/null +++ b/tools/test_npc_proportions.py @@ -0,0 +1,22 @@ +import unittest + +import npc_proportions + + +class NpcProportionTests(unittest.TestCase): + def test_every_library_character_is_measured(self): + rows = npc_proportions.report() + self.assertEqual(len(rows), 8) + self.assertEqual(len({row["character"] for row in rows}), 8) + for row in rows: + self.assertGreater(row["measurements"]["height"], 1.6) + + def test_current_library_stays_inside_the_stylized_guide(self): + for row in npc_proportions.report(): + for key, check in row["checks"].items(): + self.assertEqual(check["status"], "ok", + f"{row['character']} {key}={check['value']}") + + +if __name__ == "__main__": + unittest.main()