Audit NPC body proportions against a shared guide
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:
1 parent
7c1a4bfd69
commit
ab9c67d266
10 files changed
+329
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user