Compare commits

...
2 Commits
Author SHA1 Message Date
zwitschi 2d67e14e52 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
2026-11-03 10:39:45 +01:00
zwitschi 2443343f2b adjusted agents instructions, made more precise and incorporate blender skills 2026-11-03 10:38:39 +01:00
9 changed files with 441 additions and 27 deletions

No files matched your search

+28 -14
View File
@@ -42,8 +42,9 @@ scope (major/minor/patch), not a placeholder.
The MIT `blender-skills` pack is installed locally at `.agents/skills/blender-skills/`
(94 skills plus a shared `references/` folder). It is **gitignored on purpose**: it is not
game content, not part of the build, tests, CI or release packages, and every contributor
may or may not have it. Pi gates project `.agents/skills` behind project trust, so the
trust prompt has to be accepted before the skills load; `/skill:blender-director` forces one.
may or may not have it. The agent runtime (Pi) gates project `.agents/skills` behind project
trust, so the trust prompt must be accepted before the skills load; `/skill:blender-director`
forces one.
Use them when a task touches `tools/blender_assets.py`, `tools/npc_pipeline.py`,
`tools/bake_ao.py`, `assets/models/blender/**`, or Blender-authored venue/prop geometry.
@@ -163,6 +164,9 @@ Recommended format:
- [ ] Document the test procedure in `README.md`.
```
Recommended `DONE.md` format: a `# DONE` heading, followed by each moved level-2 section
unchanged, with a completion date line under each section heading.
Order the actual file as described in "Priority and Versioning": unversioned development tracks
first (for example character and local-map development), then versioned milestones in version
order, then future/deferred work.
@@ -254,12 +258,12 @@ Update `README.md` whenever setup instructions, commands, project structure, or
The root-level `CHANGELOG.md` records the user-facing changes for every release. It exists so players and contributors can see what changed between versions without reading the commit log.
- Add an entry for every patch, minor, and major version change.
- Follow Semantic Versioning (`major.minor.patch`): bump major for breaking or vision-level changes, minor for new features, and patch for fixes and small polish.
- Follow Semantic Versioning (`major.minor.patch`): bump major only for changes that remove or break an existing player-facing feature, save format, or config contract, or that replace a system listed as a core pillar in PLAN.md; bump minor for new user-facing features; bump patch for fixes and small polish.
- A version increment is not complete until its `CHANGELOG.md` entry, `config/version` update and
matching local release build have all been produced and verified.
- Group each entry under `Added`, `Changed`, `Fixed`, and `Removed` headings, and date every entry.
- Describe changes in user-facing terms, not as raw commit messages.
- Add the entry when its work ships. If the version number is not yet known, record it under `Unreleased` and move it into a numbered version when the version is tagged.
- Add the entry when its work ships. If the version number is not yet known, record it under `Unreleased` as a staging entry; moving it into a numbered version is a version increment and is complete only when the `config/version` update and matching local release build have also been produced and verified (see "Commit, Build and Version Discipline").
When a full executable level-2 milestone/theme in `TODO.md` is implemented, completed, and moved to `DONE.md`:
@@ -283,13 +287,10 @@ For each task:
8. Commit each coherent, verified change as it is completed. Do not wait until a whole
milestone is finished; keep commits small and descriptive and push them when the branch is
shareable.
9. On every major, minor or patch version increment: update `config/version`, add the
`CHANGELOG.md` entry, run the local release build (`tools/build_releases.sh`), verify the
artifacts and checksums, then commit, tag and push the version.
10. If a level-2 milestone from `TODO.md` has been fully completed, ensure its `CHANGELOG.md`
entry and matching build are in place, then create the next semantic version or tag.
11. Push committed changes to the shared repository if applicable.
12. Note the new version, build artifacts or tag in the relevant documentation and handoff
9. If the change is a release (a major, minor, or patch version increment), follow the Release
Procedure in "Commit, Build and Version Discipline".
10. Push committed changes to the shared repository if applicable.
11. Note the new version, build artifacts or tag in the relevant documentation and handoff
summary.
Avoid unrelated refactors unless they are required for the task or explicitly approved.
@@ -300,13 +301,24 @@ Avoid unrelated refactors unless they are required for the task or explicitly ap
milestone or release is finished. Keep the working tree clean between tasks where possible.
- **Push regularly.** Push shareable commits to the shared repository; do not accumulate a long
local-only history.
- **Build every version.** Major, minor and patch increments each require a local release build
and artifact/checksum verification before tagging. A changelog entry alone is not a release.
- **Build every version.** Every major, minor and patch increment follows the Release Procedure
below; a changelog entry alone is not a release.
- **CI is a second gate, not a substitute.** Tag builds remain automated, but the local build is
the required pre-tag check for every version.
- **Unversioned tracks stay unversioned.** Character, map and other development tracks do not get
a placeholder version; the version is assigned when the work is scoped into a release.
**Release Procedure** — follow these steps in order for every major, minor, or patch version
increment:
1. Update `config/version`.
2. Write the `CHANGELOG.md` entry for the version.
3. Run the local release build (`tools/build_releases.sh`).
4. Verify the build artifacts and checksums.
5. Commit the changes.
6. Tag the version.
7. Push the commit and tag.
## Collaboration Rules
- Prefer clear, reversible changes over broad rewrites.
@@ -316,7 +328,7 @@ Avoid unrelated refactors unless they are required for the task or explicitly ap
- Keep documentation synchronized with the implementation.
- Mention files changed and checks run in handoff summaries.
- Commit coherent increments as you go; do not wait until the end of a milestone.
- Run a release build whenever a major, minor or patch version is incremented.
- Follow the Release Procedure in "Commit, Build and Version Discipline" whenever a major, minor or patch version is incremented.
- Leave follow-up work as checkbox items in `TODO.md` rather than hiding it in prose.
- If blocked, document the blocker in `MEMORY.md` and add a corresponding task to `TODO.md`.
- Avoid overwriting another contributor’s work; inspect recent changes before editing shared files.
@@ -330,6 +342,8 @@ Before handing off work, an agent must run every check below that applies to the
- Start the game or application and perform a basic smoke test.
- For 3D/Blender changes: `blender_assets.py inspect`, `audit` and `fit`, the relevant capture
gate, and a note of which local skills informed the change.
- If the local Blender skills are not installed, state "no local skills used" in the handoff
and list the skill-free checks that were run instead.
- Check that documentation and task status reflect the actual state.
- Review the final diff for accidental changes or unfinished markers.
+9
View File
@@ -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
+14 -6
View File
@@ -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.
+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
- [`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
+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 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 <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)
+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
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
+124 -2
View File
@@ -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)
+31
View File
@@ -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."""