From 2d67e14e52ddd48825e2cca94bf4412dde5d6de4 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Tue, 3 Nov 2026 10:39:45 +0100 Subject: [PATCH] Measure the 3D library and open the model review scorecard --- MEMORY.md | 9 ++ TODO.md | 20 +++- docs/3d-model-review.md | 207 +++++++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/asset-tooling.md | 28 ++++- docs/npc-visual-review.md | 4 + tools/blender_assets.py | 126 ++++++++++++++++++++- tools/test_blender_assets.py | 31 ++++++ 8 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 docs/3d-model-review.md diff --git a/MEMORY.md b/MEMORY.md index 08c8613..a3096c0 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -4,6 +4,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## 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` 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 diff --git a/TODO.md b/TODO.md index 8d22132..488a02a 100644 --- a/TODO.md +++ b/TODO.md @@ -28,16 +28,24 @@ except the rebuilt assets themselves. ### 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`. -- [ ] 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 - not replace committed sheets until a fix lands. -- [ ] Create `docs/3d-model-review.md` with a per-asset scorecard: silhouette, anatomy/proportion, + not replace committed sheets until a fix lands. (48/48 and 96/96 frames captured under + `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, 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`, - `npc_proportions.py`, texel density, triangles/meshes, 24-NPC frame time. + (Doc created with character/gear/prop/venue tables, verdict columns and a ranked candidate + 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 per item; record the decision and the reason in the review document. diff --git a/docs/3d-model-review.md b/docs/3d-model-review.md new file mode 100644 index 0000000..39ad3dc --- /dev/null +++ b/docs/3d-model-review.md @@ -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. diff --git a/docs/README.md b/docs/README.md index ba9dbab..0bf6012 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ This folder holds the project documentation, visual references and generated QA - [`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-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 - [`accessibility.md`](accessibility.md) — accessibility statement and known gaps - [`known-limitations.md`](known-limitations.md) — release-candidate blockers and accepted limits diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index 4379861..0a60f8e 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -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 audit # no Blender needed 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 ``` @@ -160,8 +161,8 @@ flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- inspect \ --file "$PWD/assets/models/blender/npc/npc_casual.glb" \ --file "$PWD/tmp/downloads/SomeCharacter.glb" -# machine-readable, for intake scripts -... -- inspect --file "$PWD/tmp/x.glb" --json +# machine-readable, for intake scripts (write to a file: Blender prints a banner first) +... -- inspect --file "$PWD/tmp/x.glb" --json --out "$PWD/tmp/x.json" # force a contract family instead of inferring it from the path ... -- inspect --file "$PWD/tmp/x.glb" --category npc ``` @@ -182,11 +183,28 @@ Caveats: pollute bounds or the unskinned list. - 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. -- Numbers are Blender-side measurements. They agree with `audit` on project assets - (same triangle and glTF vertex counts), but `audit` stays the single final gate: - it is stdlib-only, runs in CI, and matches what the engine imports. +- Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split + vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final + 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 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 ` 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) diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md index 3d11a7d..1b2d61a 100644 --- a/docs/npc-visual-review.md +++ b/docs/npc-visual-review.md @@ -13,6 +13,10 @@ the conversation-distance hand pass [`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). +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 `src/test/capture_npc_baseline.gd` stages all eight library characters in a diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 614d12e..5787cdc 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -35,6 +35,8 @@ Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up): """ import argparse +import contextlib +import io import json import math import struct @@ -1791,6 +1793,89 @@ def audit_asset(category, name): 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) # --------------------------------------------------------------------------- @@ -1925,8 +2010,12 @@ def inspect_glb(path, category=None) -> dict: return {"ok": False, "error": f"missing file {file_path}", "issues": [], "warnings": [], "category": category or "external"} 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: - bpy.ops.import_scene.gltf(filepath=str(file_path)) + with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log): + bpy.ops.import_scene.gltf(filepath=str(file_path)) except RuntimeError as exc: return {"ok": False, "error": f"import failed: {exc}", "issues": [], "warnings": [], "category": category or "external"} @@ -1983,6 +2072,7 @@ def inspect_glb(path, category=None) -> dict: "world_bounds": world, "transform_delta": delta, "transforms_applied": applied, + "import_log": import_log.getvalue().strip().splitlines()[-3:], "detail": objects, } result = analyse_inspection(stats) @@ -2318,10 +2408,35 @@ def main(argv=None): choices=["", "npc", "npc_gear", "props", "external"], help="override the path-derived contract category") 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("list") 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 bpy is None: print("inspect must run inside Blender:\n flatpak run org.blender.Blender " @@ -2330,7 +2445,14 @@ def main(argv=None): return 2 results = [inspect_glb(f, args.category or None) for f in args.file] 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: for result in results: print_inspection(result) diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index d90cbe2..44a91c3 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -95,6 +95,37 @@ class BuiltAssetTests(unittest.TestCase): 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): """`inspect` reports on imported GLBs; the contract logic is pure and tested here with synthetic measurements so Blender is not a test dependency."""