Audit NPC body proportions against a shared guide
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

Measure all eight exported characters and record the stylized proportion bands. All variants pass; no skeleton or mesh rescale was needed.
This commit is contained in:
zwitschi committed 2026-11-02 18:54:52 +01:00
1 parent 7c1a4bfd69
commit ab9c67d266
10 files changed
+329 -3

No files matched your search

+7 -1
View File
@@ -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 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/ backpack straps were refit as flat forward bands (GLB + AO rebuilt); the face remains P1. Before/
after sheet: `sheet_npc_baseline_compare.jpg`. 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 - **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, 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 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. - 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. - 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()`. - `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). - 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 - 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 (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
+1
View File
@@ -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/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/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-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. - [`docs/adr/`](docs/adr/) — Architecture Decision Records.
- [`TODO.md`](TODO.md) — Active tasks plus the 0.x gap analysis and milestone roadmap. - [`TODO.md`](TODO.md) — Active tasks plus the 0.x gap analysis and milestone roadmap.
+4 -2
View File
@@ -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, 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 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 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. release-candidate foundation, not yet a 0.10.0 release candidate.
@@ -82,8 +82,10 @@ and equipment fit.
### Body proportions and silhouette ### 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. 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 - [ ] 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. than stacked primitives; preserve the existing broad/slim/average build variations.
- [ ] Add subtle body-shape variation between variants beyond uniform scaling (shoulder slope, - [ ] Add subtle body-shape variation between variants beyond uniform scaling (shoulder slope,
+1
View File
@@ -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 - [`getting-started.md`](getting-started.md) — setup, run/test commands and controls
- [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates - [`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-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 - [`development-status.md`](development-status.md) — implemented features and current gaps
- [`smoke-checklist.md`](smoke-checklist.md) — manual verification - [`smoke-checklist.md`](smoke-checklist.md) — manual verification
- [`ui-copy.md`](ui-copy.md) — screen, copy and button inventory - [`ui-copy.md`](ui-copy.md) — screen, copy and button inventory
+5
View File
@@ -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 over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear
contract. 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 × `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 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 camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in
+1
View File
@@ -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 - 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. - 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 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. - 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. - 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.
+79
View File
@@ -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.
+8
View File
@@ -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 `NpcAvatar.GEAR_OFFSETS` is applied after attachment and is not part of the GLB
measurement. 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 ## Baseline measurements
From `python3 tools/blender_assets.py fit` and the manifest: From `python3 tools/blender_assets.py fit` and the manifest:
+201
View File
@@ -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())
+22
View File
@@ -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()