Measure the 3D library and open the model review scorecard
Full build / Import + headless tests (push) Successful in 43s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 10:39:45 +01:00
1 parent 2443343f2b
commit 2d67e14e52
8 files changed
+412 -12

No files matched your search

+9
View File
@@ -4,6 +4,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Decisions ## Decisions
- **3D review baseline (2026-11-03):** `docs/3d-model-review.md` records the first skill-guided
measurements: 46/46 assets clean, library 59,012 tris, characters 4,482–5,474 tris (68–69% of
the 8,000 caps), `npc_base` only 1,204/2,488, 24-NPC 6.94 ms, and authored texel density
64–122 px/m (fabric effective 287–440 after the 4× repeat). That is below the skill's 128–256
background tier but the slot maps are neutral and tinted at runtime, so it is a recorded decision
point, not a defect. Added `blender_assets.py texels` (stdlib, per-slot px/m from GLB UV/world
area, fabric reported twice) and `inspect --json --out` (the glTF importer's INFO log is captured
in `stats.import_log`; Blender's startup banner still pollutes stdout). Baseline/review captures
run fine under `xvfb-run` into `tmp/`.
- **3D model re-evaluation first (2026-11-03):** `TODO.md` is re-planned around a new unversioned - **3D model re-evaluation first (2026-11-03):** `TODO.md` is re-planned around a new unversioned
`3D model re-evaluation` track, ahead of character and local-map work, because the crowd-variant `3D model re-evaluation` track, ahead of character and local-map work, because the crowd-variant
roster would otherwise multiply whatever the existing models get wrong. It scores all 8 roster would otherwise multiply whatever the existing models get wrong. It scores all 8
+14 -6
View File
@@ -28,16 +28,24 @@ except the rebuilt assets themselves.
### A. Baseline, scorecard and routing ### A. Baseline, scorecard and routing
- [ ] Load `blender-director` plus the per-family specialists before touching geometry, and - [x] Load `blender-director` plus the per-family specialists before touching geometry, and
record the skill stack and the advice that changed a decision in `docs/3d-model-review.md`. record the skill stack and the advice that changed a decision in `docs/3d-model-review.md`.
- [ ] Re-run the neutral NPC baseline (48 frames), the final-context review (96 frames), the (Routing, QA severity/verdict, texel-density tiers, character loop criteria and budget rules
recorded in the review doc; MCP-only steps translated to headless flatpak runs.)
- [x] Re-run the neutral NPC baseline (48 frames), the final-context review (96 frames), the
prop gallery and the 24-NPC budget on the current build; keep raw frames in `tmp/` and do prop gallery and the 24-NPC budget on the current build; keep raw frames in `tmp/` and do
not replace committed sheets until a fix lands. not replace committed sheets until a fix lands. (48/48 and 96/96 frames captured under
- [ ] Create `docs/3d-model-review.md` with a per-asset scorecard: silhouette, anatomy/proportion, `xvfb-run`, none blank; raw sets in `tmp/model-baseline` and `tmp/model-review`; committed
sheets untouched. 24-NPC smoke still 6.94 ms / 168 meshes / 131,616 triangles.)
- [x] Create `docs/3d-model-review.md` with a per-asset scorecard: silhouette, anatomy/proportion,
topology/edge flow, UV/texel density, textures/materials, rig/weights, LOD/impostor, topology/edge flow, UV/texel density, textures/materials, rig/weights, LOD/impostor,
collision/grounding, export contract, provenance, skill route, verdict (P0/P1/P2/accept). collision/grounding, export contract, provenance, skill route, verdict (P0/P1/P2/accept).
- [ ] Collect the baseline measurements in one table: `blender_assets.py inspect`, `audit`, `fit`, (Doc created with character/gear/prop/venue tables, verdict columns and a ranked candidate
`npc_proportions.py`, texel density, triangles/meshes, 24-NPC frame time. list; unmeasured cells are marked `—` instead of being guessed.)
- [x] Collect the baseline measurements in one table: `blender_assets.py inspect`, `audit`, `fit`,
`npc_proportions.py`, texel density, triangles/meshes, 24-NPC frame time. (All seven captured;
`texels` and `inspect --json --out` were added to make the measurements repeatable. All 46
assets audit/inspect clean; texel density 64–122 px/m authored, 287–440 effective fabric.)
- [ ] Review the scorecard with the project owner, rank the P0/P1 gaps and decide fix vs accept - [ ] Review the scorecard with the project owner, rank the P0/P1 gaps and decide fix vs accept
per item; record the decision and the reason in the review document. per item; record the decision and the reason in the review document.
+207
View File
@@ -0,0 +1,207 @@
# 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-proportion-audit.md`](npc-proportion-audit.md) (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. The skills
assume Blender MCP, which this project does not use: everything below came from the headless
`tools/blender_assets.py` / `tools/npc_pipeline.py` pipelines plus the Godot capture harnesses.
## 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, 131,616 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` | 4,482 / 4,260 | 1.824 / +0.000 | 7 | 106.8–122.2 | 429–440 | Contract pass; visual accepted 0.9.4 |
| `npc_jacket` | 4,872 / 4,662 | 1.824 / +0.000 | 7 | 100.3–113.7 | 409–413 | Contract pass; visual accepted 0.9.4 |
| `npc_coat` | 5,474 / 5,265 | 1.824 / +0.000 | 7 | 75.3–81.4 | 307–310 | Contract pass; visual accepted 0.9.4 |
| `npc_vest` | 4,980 / 4,879 | 1.819 / +0.000 | 9 | 85.5–99.3 | 362–366 | Contract pass; visual accepted 0.9.4 |
| `npc_dress` | 4,900 / 4,719 | 1.825 / +0.000 | 7 | 81.9–89.0 | 338–339 | Contract pass; visual accepted 0.9.4 |
| `npc_hoodie` | 5,304 / 4,950 | 1.856 / +0.000 | 7 | 89.5–98.6 | 359–364 | Contract pass; visual accepted 0.9.4 |
| `npc_firefighter` | 5,344 / 5,494 | 1.819 / +0.000 | 9 | 64.0–75.1 | 287–290 | Contract pass; lowest density |
| `npc_guard` | 5,046 / 5,096 | 1.819 / +0.000 | 11 | 90.0–100.8 | 374–374 | Contract pass; visual accepted 0.9.4 |
| `npc_base` (fallback) | 1,204 / 2,488 | 1.836 / +0.000 | 5 | 70.1–72.9 | 4× on fabric | Contract pass; far below its 6,000/4,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 8,000/8,000 with the farthest at 5,474 tris (68% of budget) 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.
## 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 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `npc_casual` | accept (0.9.4) | P1 (faceted planes) | accept (open palm) | — | accept | accept (two-quad impostor) | pending owner |
| `npc_jacket` | accept | P1 | accept | — | accept | accept | pending owner |
| `npc_coat` | accept | P1 | accept | — | accept (hem 0.76 stride) | accept | pending owner |
| `npc_vest` | accept | P1 | accept | — | accept | accept | pending owner |
| `npc_dress` | accept; performer props dominate | P1 | accept | — | accept | accept | pending owner |
| `npc_hoodie` | accept (hooded criterion) | accept (lower face only) | accept | — | accept | accept | pending owner |
| `npc_firefighter` | accept | P1 | accept | — | accept | accept | pending owner |
| `npc_guard` | accept | P1 | accept | — | accept | accept | pending owner |
### Gear (18)
| Gear | Attach | Tris | Verdict |
| --- | --- | --- | --- |
| `cap` | head | 328 | — (fit measured, visual pending) |
| `beanie` | head | 460 | — (unused by any character) |
| `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).
### 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 | — |
| `bench` | 560 | — |
| `trash_bin` | 228 | — |
| `planter_box` | 1,324 | — |
| `newspaper_box` | 244 | — |
| `fire_hydrant` | 466 | — |
| `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 | — |
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 | 22,680 tris / 454 meshes | shape-language review pending |
| TV Studio | — | shape-language review pending |
## 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.
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; `beanie` is unused and could be retired or reassigned.
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.
## 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.
+1
View File
@@ -21,6 +21,7 @@ This folder holds the project documentation, visual references and generated QA
- [`tutorial.md`](tutorial.md) — first-shift tutorial beats - [`tutorial.md`](tutorial.md) — first-shift tutorial beats
- [`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 - [`npc-proportion-audit.md`](npc-proportion-audit.md) — NPC proportion ratios and stylized guide results
- [`3d-model-review.md`](3d-model-review.md) — 3D model re-evaluation: measurements, scorecard and ranked gaps
- [`development-status.md`](development-status.md) — implemented features, current gaps and active planning - [`development-status.md`](development-status.md) — implemented features, current gaps and active planning
- [`accessibility.md`](accessibility.md) — accessibility statement and known gaps - [`accessibility.md`](accessibility.md) — accessibility statement and known gaps
- [`known-limitations.md`](known-limitations.md) — release-candidate blockers and accepted limits - [`known-limitations.md`](known-limitations.md) — release-candidate blockers and accepted limits
+23 -5
View File
@@ -144,6 +144,7 @@ flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
python3 tools/blender_assets.py audit # no Blender needed python3 tools/blender_assets.py audit # no Blender needed
python3 tools/blender_assets.py fit # gear-vs-body fit report python3 tools/blender_assets.py fit # gear-vs-body fit report
python3 tools/blender_assets.py texels # per-slot texel density (px/m) of the characters
godot --headless --path . --import godot --headless --path . --import
``` ```
@@ -160,8 +161,8 @@ flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- inspect \ --python "$PWD/tools/blender_assets.py" -- inspect \
--file "$PWD/assets/models/blender/npc/npc_casual.glb" \ --file "$PWD/assets/models/blender/npc/npc_casual.glb" \
--file "$PWD/tmp/downloads/SomeCharacter.glb" --file "$PWD/tmp/downloads/SomeCharacter.glb"
# machine-readable, for intake scripts # machine-readable, for intake scripts (write to a file: Blender prints a banner first)
... -- inspect --file "$PWD/tmp/x.glb" --json ... -- inspect --file "$PWD/tmp/x.glb" --json --out "$PWD/tmp/x.json"
# force a contract family instead of inferring it from the path # force a contract family instead of inferring it from the path
... -- inspect --file "$PWD/tmp/x.glb" --category npc ... -- inspect --file "$PWD/tmp/x.glb" --category npc
``` ```
@@ -182,11 +183,28 @@ Caveats:
pollute bounds or the unskinned list. pollute bounds or the unskinned list.
- Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`), - Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`),
matching the contract bands and the audit's coordinate space. matching the contract bands and the audit's coordinate space.
- Numbers are Blender-side measurements. They agree with `audit` on project assets - Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split
(same triangle and glTF vertex counts), but `audit` stays the single final gate: vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final
it is stdlib-only, runs in CI, and matches what the engine imports. gate: it is stdlib-only, runs in CI, and matches what the engine imports.
- `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the - `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the
separate (not yet built) `adapt` step. separate (not yet built) `adapt` step.
- `--json` also captures the importer's INFO log in `stats.import_log` instead of printing it.
Blender still prints its own version banner before the script runs, so intake scripts should use
`--json --out <file>` rather than parsing stdout.
### Texel density (`texels`)
`texel_density()` and the `texels` command compute `resolution * sqrt(UV area / world area)` per
material slot straight from the GLB, so the number does not depend on island packing. The NPC slot
maps are 256², and cloth/pants are reported twice: authored density and effective density with the
runtime 4× repeat from `Materials.NPC_FABRIC_UV_SCALE`. Current values and interpretation are in
[`3d-model-review.md`](3d-model-review.md).
```bash
python3 tools/blender_assets.py texels # all eight characters
python3 tools/blender_assets.py texels --json # machine-readable
python3 tools/blender_assets.py texels --only npc_coat
```
## Local map development (planned) ## Local map development (planned)
+4
View File
@@ -13,6 +13,10 @@ the conversation-distance hand pass
[`sheet_npc_hands_compare.jpg`](blender_previews/sheet_npc_hands_compare.jpg), and the [`sheet_npc_hands_compare.jpg`](blender_previews/sheet_npc_hands_compare.jpg), and the
forward-grip pass [`sheet_npc_hands_grip_compare.jpg`](blender_previews/sheet_npc_hands_grip_compare.jpg). forward-grip pass [`sheet_npc_hands_grip_compare.jpg`](blender_previews/sheet_npc_hands_grip_compare.jpg).
The 2026-11-03 skill-guided re-evaluation, its measurements (texel density, budgets, imported
bounds) and the open scorecard live in [`3d-model-review.md`](3d-model-review.md). It does not
change the accepted limits below.
## What was captured ## What was captured
`src/test/capture_npc_baseline.gd` stages all eight library characters in a `src/test/capture_npc_baseline.gd` stages all eight library characters in a
+123 -1
View File
@@ -35,6 +35,8 @@ Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up):
""" """
import argparse import argparse
import contextlib
import io
import json import json
import math import math
import struct import struct
@@ -1791,6 +1793,89 @@ def audit_asset(category, name):
return res return res
# ---------------------------------------------------------------------------
# Texel density (UV area vs world area, per material slot)
# ---------------------------------------------------------------------------
# The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat
# the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their
# effective density is four times the authored number.
TEXEL_RESOLUTION = 256
TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0}
def _tri_area3(a, b, c) -> float:
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
cx = uy * vz - uz * vy
cy = uz * vx - ux * vz
cz = ux * vy - uy * vx
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
def _tri_area2(a, b, c) -> float:
return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
def texel_density(name: str, path: Path | None = None) -> dict | None:
"""Per-slot texel density (px/m) for a character GLB.
texel density = resolution * sqrt(UV area / world surface area), so the
number is independent of how the island is packed. Effective density also
applies the runtime UV repeat for cloth/pants.
"""
path = path or asset_path("npc", name)
if not path.is_file():
return None
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return None
materials = [m.get("name", "") for m in gltf.get("materials", [])]
slots: dict[str, dict] = {}
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs
or prim.get("indices") is None):
continue
mat_index = prim.get("material")
slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
positions = _accessor(gltf, binary, attrs["POSITION"])
uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"])
indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])]
row = slots.setdefault(slot or "unassigned",
{"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0})
for k in range(0, len(indices) - 2, 3):
i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2]
if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs):
continue
row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2])
row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2])
row["triangles"] += 1
for slot, row in slots.items():
world = row["world_area_m2"]
uv = row["uv_area"]
authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0
row["td_px_per_m"] = round(authored, 1)
row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1)
row["world_area_m2"] = round(world, 3)
row["uv_area"] = round(uv, 4)
values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0]
return {
"name": name,
"resolution": TEXEL_RESOLUTION,
"slots": slots,
"min_td": min(values) if values else 0.0,
"max_td": max(values) if values else 0.0,
"total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3),
}
def texel_report() -> list[dict]:
return [row for row in (texel_density(name) for name in CHARACTERS) if row]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Intake inspection (import a GLB the way Blender sees it) # Intake inspection (import a GLB the way Blender sees it)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1925,7 +2010,11 @@ def inspect_glb(path, category=None) -> dict:
return {"ok": False, "error": f"missing file {file_path}", "issues": [], return {"ok": False, "error": f"missing file {file_path}", "issues": [],
"warnings": [], "category": category or "external"} "warnings": [], "category": category or "external"}
reset_scene() reset_scene()
# The glTF importer logs per-mesh INFO lines to stdout, which would corrupt
# `--json` output; keep the log in stats instead of printing it.
import_log = io.StringIO()
try: try:
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
bpy.ops.import_scene.gltf(filepath=str(file_path)) bpy.ops.import_scene.gltf(filepath=str(file_path))
except RuntimeError as exc: except RuntimeError as exc:
return {"ok": False, "error": f"import failed: {exc}", "issues": [], return {"ok": False, "error": f"import failed: {exc}", "issues": [],
@@ -1983,6 +2072,7 @@ def inspect_glb(path, category=None) -> dict:
"world_bounds": world, "world_bounds": world,
"transform_delta": delta, "transform_delta": delta,
"transforms_applied": applied, "transforms_applied": applied,
"import_log": import_log.getvalue().strip().splitlines()[-3:],
"detail": objects, "detail": objects,
} }
result = analyse_inspection(stats) result = analyse_inspection(stats)
@@ -2318,10 +2408,35 @@ def main(argv=None):
choices=["", "npc", "npc_gear", "props", "external"], choices=["", "npc", "npc_gear", "props", "external"],
help="override the path-derived contract category") help="override the path-derived contract category")
insp.add_argument("--json", action="store_true") insp.add_argument("--json", action="store_true")
insp.add_argument("--out", default="",
help="with --json, write to this file (Blender prints a startup banner)")
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
tex.add_argument("--only", default="")
tex.add_argument("--json", action="store_true")
sub.add_parser("sheet") sub.add_parser("sheet")
sub.add_parser("list") sub.add_parser("list")
args = ap.parse_args(raw) args = ap.parse_args(raw)
if args.cmd == "texels":
names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS)
report = [row for row in (texel_density(n) for n in names) if row]
if not report:
print("no built character assets; run build first", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2))
else:
for entry in report:
print(f"{entry['name']:16s} {entry['resolution']}px maps "
f"area={entry['total_world_area_m2']:.2f} m2 "
f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m")
for slot, row in sorted(entry["slots"].items()):
note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \
if slot in TEXEL_RUNTIME_UV_SCALE else ""
print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 "
f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} "
f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}")
return 0
if args.cmd == "inspect": if args.cmd == "inspect":
if bpy is None: if bpy is None:
print("inspect must run inside Blender:\n flatpak run org.blender.Blender " print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
@@ -2330,7 +2445,14 @@ def main(argv=None):
return 2 return 2
results = [inspect_glb(f, args.category or None) for f in args.file] results = [inspect_glb(f, args.category or None) for f in args.file]
if args.json: if args.json:
print(json.dumps(results, indent=2)) payload = json.dumps(results, indent=2)
if args.out:
Path(args.out).write_text(payload + "\n", encoding="utf-8")
print(f"wrote {args.out}")
else:
# Blender prints its version banner before this script runs, so
# scripts should prefer --out for machine-readable output.
print(payload)
else: else:
for result in results: for result in results:
print_inspection(result) print_inspection(result)
+31
View File
@@ -95,6 +95,37 @@ class BuiltAssetTests(unittest.TestCase):
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file()) self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
class TexelDensityTests(unittest.TestCase):
def test_character_slots_report_density_and_area(self):
report = ba.texel_density("npc_casual")
self.assertEqual(report["resolution"], 256)
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"})
for slot in ba.SLOTS:
row = report["slots"][slot]
self.assertGreater(row["triangles"], 0, slot)
self.assertGreater(row["world_area_m2"], 0.0, slot)
self.assertGreater(row["td_px_per_m"], 30.0, slot)
self.assertGreater(report["total_world_area_m2"], 1.0)
self.assertLess(report["total_world_area_m2"], 6.0)
def test_fabric_slots_apply_the_runtime_uv_repeat(self):
report = ba.texel_density("npc_casual")
for slot, factor in ba.TEXEL_RUNTIME_UV_SCALE.items():
row = report["slots"][slot]
self.assertAlmostEqual(row["td_runtime_px_per_m"],
row["td_px_per_m"] * factor, places=0)
skin = report["slots"]["skin"]
self.assertEqual(skin["td_px_per_m"], skin["td_runtime_px_per_m"])
def test_base_fallback_is_measurable_from_its_own_path(self):
from pathlib import Path
path = ba.ROOT / "assets" / "models" / "generated" / "npc_base.glb"
report = ba.texel_density("npc_base", path)
self.assertIsNotNone(report)
self.assertGreater(report["min_td"], 30.0)
class InspectionTests(unittest.TestCase): class InspectionTests(unittest.TestCase):
"""`inspect` reports on imported GLBs; the contract logic is pure and tested """`inspect` reports on imported GLBs; the contract logic is pure and tested
here with synthetic measurements so Blender is not a test dependency.""" here with synthetic measurements so Blender is not a test dependency."""