diff --git a/.gitignore b/.gitignore index d7df3da..9712d80 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,9 @@ assets/models/generated/npc_base_*_normal.png # Local AppImage extraction squashfs-root/ +# Blender asset library: per-asset Cycles renders are local review output (the +# committed review artefacts are docs/blender_previews/sheet_*.jpg), and Godot +# extracts the GLB-embedded textures next to the models on import. +docs/blender_previews/*.png +assets/models/blender/**/*.png +assets/models/blender_src/*.blend1 diff --git a/MEMORY.md b/MEMORY.md index 6bcc966..6c1d6f3 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -129,15 +129,20 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in `PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`. `smoke_props.gd` must be windowed because the guard reads captured-mouse input. -- **Blender asset library (v0.6.0, in progress):** `tools/blender_assets.py` generates - `assets/models/blender/{npc,npc_gear,props}/` plus `manifest.json`, `docs/blender_previews/`, - and (when finished) `docs/blender-assets.md`. Contract: 8 character variants (same bones/slots - as `npc_base`), 18 gear pieces in attach-point space (head/hand/back/chest), 22 props; the first - nine replace current furniture/lamps/trees/cone, the rest are new dressing. Treat the manifest - as the source of truth and run `python3 tools/blender_assets.py audit`/`sheet` before wiring. - At the time of writing only `npc_casual` existed (7,756 tris / 5,616 verts, above the current - 6,000/4,000 character budget) — reconcile the budget before integration. Don't edit the - generator while its Blender process is running. +- **Blender asset library (v0.6.0, built + audited 2026-11-01, not integrated):** + `tools/blender_assets.py` (`build`/`render` in Blender; `audit`/`sheet`/`list` stdlib) writes + `assets/models/blender/{npc,npc_gear,props}/*.glb`, `manifest.json` (integration source of + truth), sources in `assets/models/blender_src/` (`.gdignore`d), and review sheets + `docs/blender_previews/sheet_*.jpg` (per-asset PNG renders are gitignored). 8 characters, 18 + gear, 20 props; notes in `docs/blender-assets.md`. Characters are drop-in for `npc_base` + (same mesh node name, bones at the same rest positions, five tint slots) and stay under the + 6000/4000 budget only because of body decimation (0.36) + `smart_project` at 80°: glTF vertex + count includes UV-seam splits, so extra beveled detail quickly breaks 4000. + **Gotchas:** `bisect_plane` and thin bevels leave zero-area triangles (Godot warns "non-finite + normal in LOD generation"); `_clean()` + a weld after bisect fix it, and the audit rejects them. + Fixed-colour detail materials carry a 4×4 texture because `_apply_outfit_materials()` sets + `albedo_color = WHITE` on unknown slots. Cycles CPU render ≈ 3 s/asset; Godot import of the + library is clean. - **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it. diff --git a/PLAN.md b/PLAN.md index b0a7a4e..6533b9a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -413,7 +413,7 @@ existing AI tools remain the source of art direction for fabric textures, palett screen-design compare/judge gate. The library targets 8 character variants (casual, jacket, coat, vest, dress, hoodie, firefighter, -guard), 18 gear pieces, and 22 props — including replacements for the current generated street +guard), 18 gear pieces, and 20 props — including replacements for the current generated street furniture, lamps, trees, doors, marquee, and a venue car. Integration must preserve the `Rig/Head` focus anchor, seed-driven outfit determinism, collider/navigation footprints, and the frame budget measured by `smoke_npc_budget.gd`. See diff --git a/README.md b/README.md index e61fde5..8077c6b 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,31 @@ which is gitignored) onto the `cloth`/`pants` albedos; `build` bakes them into t `.glb` and regenerates the procedural normal + packed ORM maps. `palette` writes `assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`. +### Blender asset library (staged, not yet loaded by the game) + +`tools/blender_assets.py` models a larger library procedurally in Blender for the +v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest, +dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones, +joint positions and tint slots); **18 gear pieces** authored at head/hand/back/chest +attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup, +clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props** +(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp, +street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion, +handrail, podium, double door, marquee, sedan). Contract and integration notes: +[`docs/blender-assets.md`](docs/blender-assets.md). + +| Characters | Props | +| --- | --- | +| ![characters](docs/blender_previews/sheet_npc.jpg) | ![props](docs/blender_previews/sheet_props.jpg) | + +```bash +flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build +flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render +python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg +python3 tools/blender_assets.py audit # no Blender needed +godot --headless --path . --import +``` + ### Confirmed game assets Everything below is audited and shipped in the game; development-only outputs @@ -445,6 +470,7 @@ The full arc42-style architecture documentation lives in [`docs/architecture.md` - [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation. - [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — Graphics-quality plan: Blender-authored NPC characters. +- [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points. ## Development Status diff --git a/TODO.md b/TODO.md index f292b05..a748a21 100644 --- a/TODO.md +++ b/TODO.md @@ -1,19 +1,18 @@ # TODO Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless -suite **212/212** plus **60** Python tooling tests; the session, PLAN-verb, crowd, emergency, +suite **218/218** plus **68** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes pass. Anonymous downloads are enabled on the host. The scheduled presentation pass is complete (see `DONE.md`). The next scheduled pass is the graphics-quality work in [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md). -> **Asset build in progress:** `tools/blender_assets.py` is being run separately (flatpak -> Blender) to generate a larger library: 8 NPC character variants, 18 gear pieces, and 22 -> street/venue props under `assets/models/blender/`, with a manifest and preview renders. -> Do not edit the generator while that process is running. Treat -> `assets/models/blender/manifest.json` as the integration source of truth, and audit the -> finished outputs before wiring anything into the game. +> **Blender asset library built (2026-11-01):** `tools/blender_assets.py` generated 8 NPC +> character variants, 18 gear pieces, and 20 street/venue props under `assets/models/blender/` +> (audit clean, Godot import clean, see [`docs/blender-assets.md`](docs/blender-assets.md)). +> Treat `assets/models/blender/manifest.json` as the integration source of truth; nothing is +> wired into the game yet. ## Graphics quality — Blender-authored NPC characters (v0.6.0) @@ -65,24 +64,31 @@ Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [AD - [x] Re-measure `smoke_npc_budget.gd`; tune `LOD_TIERS` if the skinned rig is heavier up close. - [x] Re-verify the focus-cone smoke (`smoke_focus_radial.gd`) and the movement smoke. -### Phase 3.5 — Blender asset library integration (generator running) +### Phase 3.5 — Blender asset library integration Contract and outputs: [`assets/models/blender/manifest.json`](assets/models/blender/manifest.json), -generator `tools/blender_assets.py`, previews `docs/blender_previews/` (the generator's -integration notes are being written to `docs/blender-assets.md`). The library currently targets -8 `npc` variants, 18 `npc_gear` pieces, and 22 `props`; each entry records path, triangle/vertex +generator `tools/blender_assets.py`, review sheets `docs/blender_previews/sheet_*.jpg`, +integration notes [`docs/blender-assets.md`](docs/blender-assets.md). The library has +8 `npc` variants, 18 `npc_gear` pieces, and 20 `props`; each entry records path, triangle/vertex count, bounds, material slots, and (for gear) its attach point. -- [ ] Wait for the running build to finish, then run `python3 tools/blender_assets.py audit` and - reconcile every failure. The first `npc_casual` build is over the 6,000-triangle contract - (7,756 tris / 5,616 verts): reduce the mesh or raise/annotate the character budget in the - plan before integrating. -- [ ] Run `python3 tools/blender_assets.py sheet` and review the contact sheets; reject assets +- [x] Wait for the running build to finish, then run `python3 tools/blender_assets.py audit` and + reconcile every failure. Characters were reduced to fit the unchanged 6,000/4,000 contract + (now 3,788–4,640 tris / 3,195–3,962 verts); the audit also rejects zero-area triangles. +- [x] Run `python3 tools/blender_assets.py sheet` and review the contact sheets; reject assets whose silhouette, grounded origin, or face direction (+Z in Godot) is wrong. - [ ] Commit the finished library with the generator, `.blend` sources, manifest, previews, and `docs/blender-assets.md`; add generated/preview folders to `.gitignore` only where CI can - rebuild them offline. Keep the audit runnable without Blender. -- [ ] Add `tools/test_blender_assets.py` for the manifest contract (all catalogue entries present, + rebuild them offline. Keep the audit runnable without Blender. (`.gitignore` already + excludes the per-asset preview PNGs, Godot-extracted GLB textures, and `*.blend1`.) +- [x] Add `src/test/tests/test_blender_assets.gd`: library imports as scenes, characters keep + bones/slots, joints match `npc_base`, bones accept the avatar pose math, props grounded. +- [ ] Tint only the five contract slots in `_apply_outfit_materials()` (fixed-colour detail + materials such as `hivis`/`reflective`/`leather` must keep their colour), and skip the + `vest` cloth→yellow override for `npc_vest` (its vest is the fixed `hivis` material). +- [ ] Bake AO/ORM for the Blender characters and props (they currently reuse tiling slot maps + or plain PBR factors). +- [x] Add `tools/test_blender_assets.py` for the manifest contract (all catalogue entries present, audit clean, attach points known, paths inside `assets/models/blender/`). - [ ] Let `NpcAvatar` choose a character model from the manifest by the profile's role/layer (`npc_casual` / `npc_jacket` / `npc_coat` / `npc_vest` / `npc_dress` / `npc_hoodie` / diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json new file mode 100644 index 0000000..fdcb886 --- /dev/null +++ b/assets/models/blender/manifest.json @@ -0,0 +1,1240 @@ +{ + "assets": { + "a_frame_sign": { + "bounds_godot": { + "max": [ + 0.31, + 0.967, + 0.264 + ], + "min": [ + -0.31, + 0.002, + -0.264 + ] + }, + "category": "props", + "materials": [ + "dark_wood", + "chalkboard", + "chalk", + "steel" + ], + "path": "assets/models/blender/props/a_frame_sign.glb", + "triangles": 212, + "vertices": 432 + }, + "backpack": { + "attach": "back", + "bounds_godot": { + "max": [ + 0.15, + 0.3, + 0.141 + ], + "min": [ + -0.15, + -0.12, + -0.319 + ] + }, + "category": "npc_gear", + "materials": [ + "pack_nylon", + "steel", + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/backpack.glb", + "triangles": 316, + "vertices": 360 + }, + "beanie": { + "attach": "head", + "bounds_godot": { + "max": [ + 0.193, + 0.215, + 0.183 + ], + "min": [ + -0.193, + 0.014, + -0.203 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_grey" + ], + "path": "assets/models/blender/npc_gear/beanie.glb", + "triangles": 460, + "vertices": 403 + }, + "bench": { + "bounds_godot": { + "max": [ + 0.9, + 0.843, + 0.242 + ], + "min": [ + -0.9, + -0.001, + -0.28 + ] + }, + "category": "props", + "materials": [ + "wood", + "black_metal" + ], + "path": "assets/models/blender/props/bench.glb", + "replaces": "generated/bench.glb", + "triangles": 560, + "vertices": 960 + }, + "bollard": { + "bounds_godot": { + "max": [ + 0.105, + 0.975, + 0.105 + ], + "min": [ + -0.105, + 0, + -0.105 + ] + }, + "category": "props", + "materials": [ + "black_metal", + "reflective" + ], + "path": "assets/models/blender/props/bollard.glb", + "replaces": "generated/bollard.glb", + "triangles": 138, + "vertices": 225 + }, + "camera": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.106, + 0.376, + 0.143 + ], + "min": [ + -0.106, + -0.053, + -0.055 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_black", + "lens" + ], + "path": "assets/models/blender/npc_gear/camera.glb", + "triangles": 318, + "vertices": 406 + }, + "cap": { + "attach": "head", + "bounds_godot": { + "max": [ + 0.168, + 0.209, + 0.258 + ], + "min": [ + -0.168, + 0.037, + -0.186 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/cap.glb", + "triangles": 328, + "vertices": 285 + }, + "clipboard": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.115, + 0.05, + 0.053 + ], + "min": [ + -0.115, + -0.265, + 0.036 + ] + }, + "category": "npc_gear", + "materials": [ + "dark_wood", + "paper", + "steel" + ], + "path": "assets/models/blender/npc_gear/clipboard.glb", + "triangles": 84, + "vertices": 184 + }, + "crowd_barrier": { + "bounds_godot": { + "max": [ + 1.116, + 1.066, + 0.307 + ], + "min": [ + -1.122, + -0.014, + -0.307 + ] + }, + "category": "props", + "materials": [ + "steel" + ], + "path": "assets/models/blender/props/crowd_barrier.glb", + "triangles": 388, + "vertices": 664 + }, + "cup": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.046, + 0.032, + 0.046 + ], + "min": [ + -0.046, + -0.11, + -0.046 + ] + }, + "category": "npc_gear", + "materials": [ + "paper", + "plastic_white", + "cardboard" + ], + "path": "assets/models/blender/npc_gear/cup.glb", + "triangles": 188, + "vertices": 220 + }, + "double_door": { + "bounds_godot": { + "max": [ + 1.165, + 2.72, + 0.1 + ], + "min": [ + -1, + 0, + -0.1 + ] + }, + "category": "props", + "emissive": [ + "lock_led" + ], + "materials": [ + "door_paint", + "dark_glass", + "steel", + "chrome", + "plastic_black", + "lock_led" + ], + "path": "assets/models/blender/props/double_door.glb", + "triangles": 380, + "vertices": 792 + }, + "fire_hydrant": { + "bounds_godot": { + "max": [ + 0.24, + 0.76, + 0.19 + ], + "min": [ + -0.24, + 0, + -0.19 + ] + }, + "category": "props", + "materials": [ + "hydrant_red", + "chrome" + ], + "path": "assets/models/blender/props/fire_hydrant.glb", + "replaces": "generated/fire_hydrant.glb", + "triangles": 466, + "vertices": 725 + }, + "firefighter_helmet": { + "attach": "head", + "bounds_godot": { + "max": [ + 0.26, + 0.225, + 0.26 + ], + "min": [ + -0.26, + 0.015, + -0.351 + ] + }, + "category": "npc_gear", + "materials": [ + "helmet", + "hivis", + "brass" + ], + "path": "assets/models/blender/npc_gear/firefighter_helmet.glb", + "triangles": 672, + "vertices": 699 + }, + "flashlight": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.026, + -0.023, + 0.163 + ], + "min": [ + -0.026, + -0.077, + -0.08 + ] + }, + "category": "npc_gear", + "materials": [ + "black_metal", + "bulb", + "rubber" + ], + "path": "assets/models/blender/npc_gear/flashlight.glb", + "triangles": 168, + "vertices": 216 + }, + "handrail": { + "bounds_godot": { + "max": [ + 2.102, + 1.784, + 0.05 + ], + "min": [ + -0.107, + 0, + -0.05 + ] + }, + "category": "props", + "materials": [ + "black_metal" + ], + "path": "assets/models/blender/props/handrail.glb", + "triangles": 268, + "vertices": 400 + }, + "hood": { + "attach": "head", + "bounds_godot": { + "max": [ + 0.184, + 0.236, + 0.123 + ], + "min": [ + -0.184, + -0.146, + -0.225 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_grey" + ], + "path": "assets/models/blender/npc_gear/hood.glb", + "triangles": 804, + "vertices": 649 + }, + "lanyard": { + "attach": "chest", + "bounds_godot": { + "max": [ + 0.075, + 0.164, + 0.15 + ], + "min": [ + -0.075, + -0.233, + -0.003 + ] + }, + "category": "npc_gear", + "materials": [ + "ribbon_blue", + "plastic_white" + ], + "path": "assets/models/blender/npc_gear/lanyard.glb", + "triangles": 100, + "vertices": 182 + }, + "marquee": { + "bounds_godot": { + "max": [ + 3, + 1.914, + 1.67 + ], + "min": [ + -3, + -0.13, + 0 + ] + }, + "category": "props", + "emissive": [ + "bulb", + "sign_face" + ], + "materials": [ + "black_metal", + "sign_face", + "bulb" + ], + "path": "assets/models/blender/props/marquee.glb", + "triangles": 2804, + "vertices": 3316 + }, + "newspaper_box": { + "bounds_godot": { + "max": [ + 0.22, + 0.916, + 0.195 + ], + "min": [ + -0.22, + 0, + -0.191 + ] + }, + "category": "props", + "materials": [ + "news_blue", + "glass", + "paper", + "steel" + ], + "path": "assets/models/blender/props/newspaper_box.glb", + "replaces": "generated/newspaper_box.glb", + "triangles": 244, + "vertices": 528 + }, + "npc_casual": { + "bounds_godot": { + "max": [ + 0.321, + 1.824, + 0.186 + ], + "min": [ + -0.321, + 0, + -0.178 + ] + }, + "category": "npc", + "layer": "none", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_casual.glb", + "roles": [ + "tourist", + "confused_visitor", + "intoxicated", + "guest" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 3788, + "vertices": 3107 + }, + "npc_coat": { + "bounds_godot": { + "max": [ + 0.327, + 1.824, + 0.186 + ], + "min": [ + -0.327, + 0, + -0.18 + ] + }, + "category": "npc", + "layer": "coat", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_coat.glb", + "roles": [ + "influencer", + "trespasser", + "violent_threat" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4640, + "vertices": 3974 + }, + "npc_dress": { + "bounds_godot": { + "max": [ + 0.288, + 1.825, + 0.208 + ], + "min": [ + -0.288, + 0, + -0.208 + ] + }, + "category": "npc", + "layer": "dress", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_dress.glb", + "roles": [ + "performer" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4226, + "vertices": 3474 + }, + "npc_firefighter": { + "bounds_godot": { + "max": [ + 0.37, + 1.819, + 0.213 + ], + "min": [ + -0.37, + 0, + -0.213 + ] + }, + "category": "npc", + "layer": "responder", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "hivis", + "reflective", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_firefighter.glb", + "roles": [ + "responder" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4382, + "vertices": 3919 + }, + "npc_guard": { + "bounds_godot": { + "max": [ + 0.361, + 1.819, + 0.186 + ], + "min": [ + -0.361, + 0, + -0.172 + ] + }, + "category": "npc", + "layer": "guard", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber", + "leather", + "steel", + "patch", + "plastic_black" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_guard.glb", + "roles": [ + "player_guard", + "venue_security" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4154, + "vertices": 3838 + }, + "npc_hoodie": { + "bounds_godot": { + "max": [ + 0.326, + 1.856, + 0.186 + ], + "min": [ + -0.326, + 0, + -0.225 + ] + }, + "category": "npc", + "layer": "hood", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_hoodie.glb", + "roles": [ + "trespasser", + "mental_crisis", + "violent_threat" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4640, + "vertices": 3954 + }, + "npc_jacket": { + "bounds_godot": { + "max": [ + 0.326, + 1.824, + 0.186 + ], + "min": [ + -0.326, + 0, + -0.178 + ] + }, + "category": "npc", + "layer": "jacket", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_jacket.glb", + "roles": [ + "authorized_staff", + "protester", + "guest" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4016, + "vertices": 3415 + }, + "npc_vest": { + "bounds_godot": { + "max": [ + 0.321, + 1.819, + 0.186 + ], + "min": [ + -0.321, + 0, + -0.172 + ] + }, + "category": "npc", + "layer": "vest", + "materials": [ + "skin", + "cloth", + "pants", + "accessory", + "hair", + "rubber", + "hivis", + "reflective" + ], + "mesh_node": "npc_base", + "path": "assets/models/blender/npc/npc_vest.glb", + "roles": [ + "delivery" + ], + "tint_slots": [ + "skin", + "cloth", + "pants", + "accessory", + "hair" + ], + "triangles": 4284, + "vertices": 3784 + }, + "palm_tree": { + "bounds_godot": { + "max": [ + 2.29, + 6.99, + 1.95 + ], + "min": [ + -1.71, + 0, + -1.95 + ] + }, + "category": "props", + "materials": [ + "palm_bark", + "palm_leaf" + ], + "path": "assets/models/blender/props/palm_tree.glb", + "triangles": 400, + "vertices": 482 + }, + "phone": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.038, + 0.045, + 0.027 + ], + "min": [ + -0.038, + -0.105, + 0.014 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_black", + "screen" + ], + "path": "assets/models/blender/npc_gear/phone.glb", + "triangles": 96, + "vertices": 120 + }, + "planter_box": { + "bounds_godot": { + "max": [ + 0.5, + 1.08, + 0.5 + ], + "min": [ + -0.5, + 0, + -0.5 + ] + }, + "category": "props", + "materials": [ + "stone_pot", + "soil", + "foliage" + ], + "path": "assets/models/blender/props/planter_box.glb", + "replaces": "generated/planter_box.glb", + "triangles": 924, + "vertices": 874 + }, + "protest_sign": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.31, + 0.98, + 0.033 + ], + "min": [ + -0.31, + -0.25, + -0.015 + ] + }, + "category": "npc_gear", + "materials": [ + "wood", + "cardboard", + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/protest_sign.glb", + "triangles": 92, + "vertices": 192 + }, + "radio": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.029, + 0.095, + 0.02 + ], + "min": [ + -0.029, + -0.108, + -0.016 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_black", + "rubber", + "plastic_grey", + "dark_metal", + "lock_led" + ], + "path": "assets/models/blender/npc_gear/radio.glb", + "triangles": 220, + "vertices": 272 + }, + "round_planter": { + "bounds_godot": { + "max": [ + 0.394, + 1.12, + 0.375 + ], + "min": [ + -0.39, + 0, + -0.38 + ] + }, + "category": "props", + "materials": [ + "stone_pot", + "foliage" + ], + "path": "assets/models/blender/props/round_planter.glb", + "triangles": 920, + "vertices": 896 + }, + "scba_tank": { + "attach": "back", + "bounds_godot": { + "max": [ + 0.15, + 0.385, + 0.155 + ], + "min": [ + -0.15, + -0.32, + -0.295 + ] + }, + "category": "npc_gear", + "materials": [ + "steel", + "brass", + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/scba_tank.glb", + "triangles": 362, + "vertices": 475 + }, + "security_podium": { + "bounds_godot": { + "max": [ + 0.32, + 1.167, + 0.23 + ], + "min": [ + -0.32, + 0, + -0.257 + ] + }, + "category": "props", + "materials": [ + "plastic_black", + "dark_wood", + "black_metal", + "patch", + "screen" + ], + "path": "assets/models/blender/props/security_podium.glb", + "triangles": 184, + "vertices": 400 + }, + "sedan": { + "bounds_godot": { + "max": [ + 0.925, + 1.325, + 2.315 + ], + "min": [ + -0.925, + 0.005, + -2.31 + ] + }, + "category": "props", + "emissive": [ + "headlight" + ], + "materials": [ + "car_paint", + "dark_glass", + "rubber", + "chrome", + "headlight", + "lock_led" + ], + "path": "assets/models/blender/props/sedan.glb", + "triangles": 928, + "vertices": 1220 + }, + "shoulder_bag": { + "attach": "back", + "bounds_godot": { + "max": [ + 0.25, + 0.348, + 0.139 + ], + "min": [ + -0.168, + -0.32, + -0.149 + ] + }, + "category": "npc_gear", + "materials": [ + "leather" + ], + "path": "assets/models/blender/npc_gear/shoulder_bag.glb", + "triangles": 188, + "vertices": 212 + }, + "stanchion": { + "bounds_godot": { + "max": [ + 0.925, + 1.01, + 0.17 + ], + "min": [ + -0.17, + 0, + -0.17 + ] + }, + "category": "props", + "materials": [ + "chrome", + "velvet_red", + "brass" + ], + "path": "assets/models/blender/props/stanchion.glb", + "triangles": 434, + "vertices": 419 + }, + "street_lamp": { + "bounds_godot": { + "max": [ + 0.2, + 4.985, + 0.25 + ], + "min": [ + -1.1, + 0, + -0.25 + ] + }, + "category": "props", + "emissive": [ + "bulb" + ], + "materials": [ + "black_metal", + "bulb" + ], + "path": "assets/models/blender/props/street_lamp.glb", + "replaces": "WorldBuilder lamp boxes", + "triangles": 428, + "vertices": 558 + }, + "street_tree": { + "bounds_godot": { + "max": [ + 1.019, + 3.65, + 1.117 + ], + "min": [ + -1.077, + 0, + -1.016 + ] + }, + "category": "props", + "materials": [ + "bark", + "foliage", + "foliage_dark", + "dark_metal" + ], + "path": "assets/models/blender/props/street_tree.glb", + "replaces": "WorldBuilder cone trees", + "triangles": 1472, + "vertices": 1396 + }, + "sunglasses": { + "attach": "head", + "bounds_godot": { + "max": [ + 0.141, + 0.065, + 0.158 + ], + "min": [ + -0.141, + 0.005, + 0.003 + ] + }, + "category": "npc_gear", + "materials": [ + "lens", + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/sunglasses.glb", + "triangles": 164, + "vertices": 248 + }, + "traffic_cone": { + "bounds_godot": { + "max": [ + 0.19, + 0.69, + 0.19 + ], + "min": [ + -0.19, + 0, + -0.19 + ] + }, + "category": "props", + "materials": [ + "rubber", + "cone_orange", + "reflective" + ], + "path": "assets/models/blender/props/traffic_cone.glb", + "replaces": "prop accessory 'cone'", + "triangles": 210, + "vertices": 281 + }, + "trash_bin": { + "bounds_godot": { + "max": [ + 0.263, + 0.92, + 0.273 + ], + "min": [ + -0.263, + 0, + -0.263 + ] + }, + "category": "props", + "materials": [ + "bin_green", + "plastic_black", + "dark_metal" + ], + "path": "assets/models/blender/props/trash_bin.glb", + "replaces": "generated/trash_bin.glb", + "triangles": 228, + "vertices": 441 + }, + "umbrella": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.45, + 0.61, + 0.45 + ], + "min": [ + -0.45, + -0.201, + -0.45 + ] + }, + "category": "npc_gear", + "materials": [ + "dark_wood", + "black_metal", + "plastic_black" + ], + "path": "assets/models/blender/npc_gear/umbrella.glb", + "triangles": 134, + "vertices": 165 + }, + "wall_lantern": { + "bounds_godot": { + "max": [ + 0.11, + 0.16, + 0.35 + ], + "min": [ + -0.11, + -0.17, + -0.015 + ] + }, + "category": "props", + "emissive": [ + "bulb" + ], + "materials": [ + "black_metal", + "bulb" + ], + "path": "assets/models/blender/props/wall_lantern.glb", + "triangles": 180, + "vertices": 322 + }, + "wand": { + "attach": "hand", + "bounds_godot": { + "max": [ + 0.038, + 0.275, + 0.023 + ], + "min": [ + -0.038, + -0.125, + -0.019 + ] + }, + "category": "npc_gear", + "materials": [ + "plastic_black", + "plastic_grey", + "lock_led" + ], + "path": "assets/models/blender/npc_gear/wand.glb", + "triangles": 244, + "vertices": 302 + } + }, + "attach_points": { + "back": "Rig root, origin at the spine centre (0, 1.10, 0)", + "chest": "Rig root, origin at the sternum (0, 1.30, 0)", + "hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin", + "head": "Rig/Head marker (y = 1.62 m, head centre)" + }, + "generator": "tools/blender_assets.py" +} diff --git a/assets/models/blender/npc/npc_casual.glb b/assets/models/blender/npc/npc_casual.glb new file mode 100644 index 0000000..90f3087 Binary files /dev/null and b/assets/models/blender/npc/npc_casual.glb differ diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb new file mode 100644 index 0000000..790da8a Binary files /dev/null and b/assets/models/blender/npc/npc_coat.glb differ diff --git a/assets/models/blender/npc/npc_dress.glb b/assets/models/blender/npc/npc_dress.glb new file mode 100644 index 0000000..45cc4de Binary files /dev/null and b/assets/models/blender/npc/npc_dress.glb differ diff --git a/assets/models/blender/npc/npc_firefighter.glb b/assets/models/blender/npc/npc_firefighter.glb new file mode 100644 index 0000000..cb7fa3d Binary files /dev/null and b/assets/models/blender/npc/npc_firefighter.glb differ diff --git a/assets/models/blender/npc/npc_guard.glb b/assets/models/blender/npc/npc_guard.glb new file mode 100644 index 0000000..6520657 Binary files /dev/null and b/assets/models/blender/npc/npc_guard.glb differ diff --git a/assets/models/blender/npc/npc_hoodie.glb b/assets/models/blender/npc/npc_hoodie.glb new file mode 100644 index 0000000..91ed064 Binary files /dev/null and b/assets/models/blender/npc/npc_hoodie.glb differ diff --git a/assets/models/blender/npc/npc_jacket.glb b/assets/models/blender/npc/npc_jacket.glb new file mode 100644 index 0000000..5127cd6 Binary files /dev/null and b/assets/models/blender/npc/npc_jacket.glb differ diff --git a/assets/models/blender/npc/npc_vest.glb b/assets/models/blender/npc/npc_vest.glb new file mode 100644 index 0000000..370eb97 Binary files /dev/null and b/assets/models/blender/npc/npc_vest.glb differ diff --git a/assets/models/blender/npc_gear/backpack.glb b/assets/models/blender/npc_gear/backpack.glb new file mode 100644 index 0000000..0471f02 Binary files /dev/null and b/assets/models/blender/npc_gear/backpack.glb differ diff --git a/assets/models/blender/npc_gear/beanie.glb b/assets/models/blender/npc_gear/beanie.glb new file mode 100644 index 0000000..5fc42ef Binary files /dev/null and b/assets/models/blender/npc_gear/beanie.glb differ diff --git a/assets/models/blender/npc_gear/camera.glb b/assets/models/blender/npc_gear/camera.glb new file mode 100644 index 0000000..3d050e4 Binary files /dev/null and b/assets/models/blender/npc_gear/camera.glb differ diff --git a/assets/models/blender/npc_gear/cap.glb b/assets/models/blender/npc_gear/cap.glb new file mode 100644 index 0000000..65a2f2b Binary files /dev/null and b/assets/models/blender/npc_gear/cap.glb differ diff --git a/assets/models/blender/npc_gear/clipboard.glb b/assets/models/blender/npc_gear/clipboard.glb new file mode 100644 index 0000000..4efbc47 Binary files /dev/null and b/assets/models/blender/npc_gear/clipboard.glb differ diff --git a/assets/models/blender/npc_gear/cup.glb b/assets/models/blender/npc_gear/cup.glb new file mode 100644 index 0000000..d81c76d Binary files /dev/null and b/assets/models/blender/npc_gear/cup.glb differ diff --git a/assets/models/blender/npc_gear/firefighter_helmet.glb b/assets/models/blender/npc_gear/firefighter_helmet.glb new file mode 100644 index 0000000..b09452d Binary files /dev/null and b/assets/models/blender/npc_gear/firefighter_helmet.glb differ diff --git a/assets/models/blender/npc_gear/flashlight.glb b/assets/models/blender/npc_gear/flashlight.glb new file mode 100644 index 0000000..ad0d3a0 Binary files /dev/null and b/assets/models/blender/npc_gear/flashlight.glb differ diff --git a/assets/models/blender/npc_gear/hood.glb b/assets/models/blender/npc_gear/hood.glb new file mode 100644 index 0000000..975df57 Binary files /dev/null and b/assets/models/blender/npc_gear/hood.glb differ diff --git a/assets/models/blender/npc_gear/lanyard.glb b/assets/models/blender/npc_gear/lanyard.glb new file mode 100644 index 0000000..ea0f1dd Binary files /dev/null and b/assets/models/blender/npc_gear/lanyard.glb differ diff --git a/assets/models/blender/npc_gear/phone.glb b/assets/models/blender/npc_gear/phone.glb new file mode 100644 index 0000000..fa46522 Binary files /dev/null and b/assets/models/blender/npc_gear/phone.glb differ diff --git a/assets/models/blender/npc_gear/protest_sign.glb b/assets/models/blender/npc_gear/protest_sign.glb new file mode 100644 index 0000000..5bef5ad Binary files /dev/null and b/assets/models/blender/npc_gear/protest_sign.glb differ diff --git a/assets/models/blender/npc_gear/radio.glb b/assets/models/blender/npc_gear/radio.glb new file mode 100644 index 0000000..16bf3cd Binary files /dev/null and b/assets/models/blender/npc_gear/radio.glb differ diff --git a/assets/models/blender/npc_gear/scba_tank.glb b/assets/models/blender/npc_gear/scba_tank.glb new file mode 100644 index 0000000..e3d12ff Binary files /dev/null and b/assets/models/blender/npc_gear/scba_tank.glb differ diff --git a/assets/models/blender/npc_gear/shoulder_bag.glb b/assets/models/blender/npc_gear/shoulder_bag.glb new file mode 100644 index 0000000..720dfd6 Binary files /dev/null and b/assets/models/blender/npc_gear/shoulder_bag.glb differ diff --git a/assets/models/blender/npc_gear/sunglasses.glb b/assets/models/blender/npc_gear/sunglasses.glb new file mode 100644 index 0000000..d95efb7 Binary files /dev/null and b/assets/models/blender/npc_gear/sunglasses.glb differ diff --git a/assets/models/blender/npc_gear/umbrella.glb b/assets/models/blender/npc_gear/umbrella.glb new file mode 100644 index 0000000..c610dca Binary files /dev/null and b/assets/models/blender/npc_gear/umbrella.glb differ diff --git a/assets/models/blender/npc_gear/wand.glb b/assets/models/blender/npc_gear/wand.glb new file mode 100644 index 0000000..12d38f2 Binary files /dev/null and b/assets/models/blender/npc_gear/wand.glb differ diff --git a/assets/models/blender/props/a_frame_sign.glb b/assets/models/blender/props/a_frame_sign.glb new file mode 100644 index 0000000..8ce30cd Binary files /dev/null and b/assets/models/blender/props/a_frame_sign.glb differ diff --git a/assets/models/blender/props/bench.glb b/assets/models/blender/props/bench.glb new file mode 100644 index 0000000..da58a15 Binary files /dev/null and b/assets/models/blender/props/bench.glb differ diff --git a/assets/models/blender/props/bollard.glb b/assets/models/blender/props/bollard.glb new file mode 100644 index 0000000..4a41d1e Binary files /dev/null and b/assets/models/blender/props/bollard.glb differ diff --git a/assets/models/blender/props/crowd_barrier.glb b/assets/models/blender/props/crowd_barrier.glb new file mode 100644 index 0000000..35cdca0 Binary files /dev/null and b/assets/models/blender/props/crowd_barrier.glb differ diff --git a/assets/models/blender/props/double_door.glb b/assets/models/blender/props/double_door.glb new file mode 100644 index 0000000..b005c71 Binary files /dev/null and b/assets/models/blender/props/double_door.glb differ diff --git a/assets/models/blender/props/fire_hydrant.glb b/assets/models/blender/props/fire_hydrant.glb new file mode 100644 index 0000000..39fe53b Binary files /dev/null and b/assets/models/blender/props/fire_hydrant.glb differ diff --git a/assets/models/blender/props/handrail.glb b/assets/models/blender/props/handrail.glb new file mode 100644 index 0000000..1a2161b Binary files /dev/null and b/assets/models/blender/props/handrail.glb differ diff --git a/assets/models/blender/props/marquee.glb b/assets/models/blender/props/marquee.glb new file mode 100644 index 0000000..cfb21ef Binary files /dev/null and b/assets/models/blender/props/marquee.glb differ diff --git a/assets/models/blender/props/newspaper_box.glb b/assets/models/blender/props/newspaper_box.glb new file mode 100644 index 0000000..598d1e7 Binary files /dev/null and b/assets/models/blender/props/newspaper_box.glb differ diff --git a/assets/models/blender/props/palm_tree.glb b/assets/models/blender/props/palm_tree.glb new file mode 100644 index 0000000..e365c41 Binary files /dev/null and b/assets/models/blender/props/palm_tree.glb differ diff --git a/assets/models/blender/props/planter_box.glb b/assets/models/blender/props/planter_box.glb new file mode 100644 index 0000000..8ceffdd Binary files /dev/null and b/assets/models/blender/props/planter_box.glb differ diff --git a/assets/models/blender/props/round_planter.glb b/assets/models/blender/props/round_planter.glb new file mode 100644 index 0000000..06dc47f Binary files /dev/null and b/assets/models/blender/props/round_planter.glb differ diff --git a/assets/models/blender/props/security_podium.glb b/assets/models/blender/props/security_podium.glb new file mode 100644 index 0000000..ca5688e Binary files /dev/null and b/assets/models/blender/props/security_podium.glb differ diff --git a/assets/models/blender/props/sedan.glb b/assets/models/blender/props/sedan.glb new file mode 100644 index 0000000..ed5806b Binary files /dev/null and b/assets/models/blender/props/sedan.glb differ diff --git a/assets/models/blender/props/stanchion.glb b/assets/models/blender/props/stanchion.glb new file mode 100644 index 0000000..6f29c05 Binary files /dev/null and b/assets/models/blender/props/stanchion.glb differ diff --git a/assets/models/blender/props/street_lamp.glb b/assets/models/blender/props/street_lamp.glb new file mode 100644 index 0000000..f56f4c5 Binary files /dev/null and b/assets/models/blender/props/street_lamp.glb differ diff --git a/assets/models/blender/props/street_tree.glb b/assets/models/blender/props/street_tree.glb new file mode 100644 index 0000000..a51113b Binary files /dev/null and b/assets/models/blender/props/street_tree.glb differ diff --git a/assets/models/blender/props/traffic_cone.glb b/assets/models/blender/props/traffic_cone.glb new file mode 100644 index 0000000..ba1e24f Binary files /dev/null and b/assets/models/blender/props/traffic_cone.glb differ diff --git a/assets/models/blender/props/trash_bin.glb b/assets/models/blender/props/trash_bin.glb new file mode 100644 index 0000000..39d12fe Binary files /dev/null and b/assets/models/blender/props/trash_bin.glb differ diff --git a/assets/models/blender/props/wall_lantern.glb b/assets/models/blender/props/wall_lantern.glb new file mode 100644 index 0000000..7df48a0 Binary files /dev/null and b/assets/models/blender/props/wall_lantern.glb differ diff --git a/assets/models/blender_src/.gdignore b/assets/models/blender_src/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/assets/models/blender_src/a_frame_sign.blend b/assets/models/blender_src/a_frame_sign.blend new file mode 100644 index 0000000..5494bf2 Binary files /dev/null and b/assets/models/blender_src/a_frame_sign.blend differ diff --git a/assets/models/blender_src/backpack.blend b/assets/models/blender_src/backpack.blend new file mode 100644 index 0000000..15902f7 Binary files /dev/null and b/assets/models/blender_src/backpack.blend differ diff --git a/assets/models/blender_src/beanie.blend b/assets/models/blender_src/beanie.blend new file mode 100644 index 0000000..79e2c14 Binary files /dev/null and b/assets/models/blender_src/beanie.blend differ diff --git a/assets/models/blender_src/bench.blend b/assets/models/blender_src/bench.blend new file mode 100644 index 0000000..5ff8295 Binary files /dev/null and b/assets/models/blender_src/bench.blend differ diff --git a/assets/models/blender_src/bollard.blend b/assets/models/blender_src/bollard.blend new file mode 100644 index 0000000..394b974 Binary files /dev/null and b/assets/models/blender_src/bollard.blend differ diff --git a/assets/models/blender_src/camera.blend b/assets/models/blender_src/camera.blend new file mode 100644 index 0000000..40a321d Binary files /dev/null and b/assets/models/blender_src/camera.blend differ diff --git a/assets/models/blender_src/cap.blend b/assets/models/blender_src/cap.blend new file mode 100644 index 0000000..14bce21 Binary files /dev/null and b/assets/models/blender_src/cap.blend differ diff --git a/assets/models/blender_src/clipboard.blend b/assets/models/blender_src/clipboard.blend new file mode 100644 index 0000000..47e4807 Binary files /dev/null and b/assets/models/blender_src/clipboard.blend differ diff --git a/assets/models/blender_src/crowd_barrier.blend b/assets/models/blender_src/crowd_barrier.blend new file mode 100644 index 0000000..f6e2822 Binary files /dev/null and b/assets/models/blender_src/crowd_barrier.blend differ diff --git a/assets/models/blender_src/cup.blend b/assets/models/blender_src/cup.blend new file mode 100644 index 0000000..42fcf38 Binary files /dev/null and b/assets/models/blender_src/cup.blend differ diff --git a/assets/models/blender_src/double_door.blend b/assets/models/blender_src/double_door.blend new file mode 100644 index 0000000..ed55dad Binary files /dev/null and b/assets/models/blender_src/double_door.blend differ diff --git a/assets/models/blender_src/fire_hydrant.blend b/assets/models/blender_src/fire_hydrant.blend new file mode 100644 index 0000000..cc40fa8 Binary files /dev/null and b/assets/models/blender_src/fire_hydrant.blend differ diff --git a/assets/models/blender_src/firefighter_helmet.blend b/assets/models/blender_src/firefighter_helmet.blend new file mode 100644 index 0000000..ebb63bf Binary files /dev/null and b/assets/models/blender_src/firefighter_helmet.blend differ diff --git a/assets/models/blender_src/flashlight.blend b/assets/models/blender_src/flashlight.blend new file mode 100644 index 0000000..e29154b Binary files /dev/null and b/assets/models/blender_src/flashlight.blend differ diff --git a/assets/models/blender_src/handrail.blend b/assets/models/blender_src/handrail.blend new file mode 100644 index 0000000..5b76f88 Binary files /dev/null and b/assets/models/blender_src/handrail.blend differ diff --git a/assets/models/blender_src/hood.blend b/assets/models/blender_src/hood.blend new file mode 100644 index 0000000..46797d2 Binary files /dev/null and b/assets/models/blender_src/hood.blend differ diff --git a/assets/models/blender_src/lanyard.blend b/assets/models/blender_src/lanyard.blend new file mode 100644 index 0000000..eeb4676 Binary files /dev/null and b/assets/models/blender_src/lanyard.blend differ diff --git a/assets/models/blender_src/marquee.blend b/assets/models/blender_src/marquee.blend new file mode 100644 index 0000000..277b59a Binary files /dev/null and b/assets/models/blender_src/marquee.blend differ diff --git a/assets/models/blender_src/newspaper_box.blend b/assets/models/blender_src/newspaper_box.blend new file mode 100644 index 0000000..15e0081 Binary files /dev/null and b/assets/models/blender_src/newspaper_box.blend differ diff --git a/assets/models/blender_src/npc_casual.blend b/assets/models/blender_src/npc_casual.blend new file mode 100644 index 0000000..b600b7c Binary files /dev/null and b/assets/models/blender_src/npc_casual.blend differ diff --git a/assets/models/blender_src/npc_coat.blend b/assets/models/blender_src/npc_coat.blend new file mode 100644 index 0000000..daa43d0 Binary files /dev/null and b/assets/models/blender_src/npc_coat.blend differ diff --git a/assets/models/blender_src/npc_dress.blend b/assets/models/blender_src/npc_dress.blend new file mode 100644 index 0000000..a3beb6d Binary files /dev/null and b/assets/models/blender_src/npc_dress.blend differ diff --git a/assets/models/blender_src/npc_firefighter.blend b/assets/models/blender_src/npc_firefighter.blend new file mode 100644 index 0000000..fec6913 Binary files /dev/null and b/assets/models/blender_src/npc_firefighter.blend differ diff --git a/assets/models/blender_src/npc_guard.blend b/assets/models/blender_src/npc_guard.blend new file mode 100644 index 0000000..7507494 Binary files /dev/null and b/assets/models/blender_src/npc_guard.blend differ diff --git a/assets/models/blender_src/npc_hoodie.blend b/assets/models/blender_src/npc_hoodie.blend new file mode 100644 index 0000000..b3930a3 Binary files /dev/null and b/assets/models/blender_src/npc_hoodie.blend differ diff --git a/assets/models/blender_src/npc_jacket.blend b/assets/models/blender_src/npc_jacket.blend new file mode 100644 index 0000000..6c13acc Binary files /dev/null and b/assets/models/blender_src/npc_jacket.blend differ diff --git a/assets/models/blender_src/npc_vest.blend b/assets/models/blender_src/npc_vest.blend new file mode 100644 index 0000000..df0db2e Binary files /dev/null and b/assets/models/blender_src/npc_vest.blend differ diff --git a/assets/models/blender_src/palm_tree.blend b/assets/models/blender_src/palm_tree.blend new file mode 100644 index 0000000..3755e13 Binary files /dev/null and b/assets/models/blender_src/palm_tree.blend differ diff --git a/assets/models/blender_src/phone.blend b/assets/models/blender_src/phone.blend new file mode 100644 index 0000000..7def227 Binary files /dev/null and b/assets/models/blender_src/phone.blend differ diff --git a/assets/models/blender_src/planter_box.blend b/assets/models/blender_src/planter_box.blend new file mode 100644 index 0000000..c783ba4 Binary files /dev/null and b/assets/models/blender_src/planter_box.blend differ diff --git a/assets/models/blender_src/protest_sign.blend b/assets/models/blender_src/protest_sign.blend new file mode 100644 index 0000000..c61e467 Binary files /dev/null and b/assets/models/blender_src/protest_sign.blend differ diff --git a/assets/models/blender_src/radio.blend b/assets/models/blender_src/radio.blend new file mode 100644 index 0000000..d9cabcb Binary files /dev/null and b/assets/models/blender_src/radio.blend differ diff --git a/assets/models/blender_src/round_planter.blend b/assets/models/blender_src/round_planter.blend new file mode 100644 index 0000000..f0a31e1 Binary files /dev/null and b/assets/models/blender_src/round_planter.blend differ diff --git a/assets/models/blender_src/scba_tank.blend b/assets/models/blender_src/scba_tank.blend new file mode 100644 index 0000000..a1123cb Binary files /dev/null and b/assets/models/blender_src/scba_tank.blend differ diff --git a/assets/models/blender_src/security_podium.blend b/assets/models/blender_src/security_podium.blend new file mode 100644 index 0000000..728f2de Binary files /dev/null and b/assets/models/blender_src/security_podium.blend differ diff --git a/assets/models/blender_src/sedan.blend b/assets/models/blender_src/sedan.blend new file mode 100644 index 0000000..0f7e9e8 Binary files /dev/null and b/assets/models/blender_src/sedan.blend differ diff --git a/assets/models/blender_src/shoulder_bag.blend b/assets/models/blender_src/shoulder_bag.blend new file mode 100644 index 0000000..46641ba Binary files /dev/null and b/assets/models/blender_src/shoulder_bag.blend differ diff --git a/assets/models/blender_src/stanchion.blend b/assets/models/blender_src/stanchion.blend new file mode 100644 index 0000000..7227808 Binary files /dev/null and b/assets/models/blender_src/stanchion.blend differ diff --git a/assets/models/blender_src/street_lamp.blend b/assets/models/blender_src/street_lamp.blend new file mode 100644 index 0000000..f97c390 Binary files /dev/null and b/assets/models/blender_src/street_lamp.blend differ diff --git a/assets/models/blender_src/street_tree.blend b/assets/models/blender_src/street_tree.blend new file mode 100644 index 0000000..a9407ce Binary files /dev/null and b/assets/models/blender_src/street_tree.blend differ diff --git a/assets/models/blender_src/sunglasses.blend b/assets/models/blender_src/sunglasses.blend new file mode 100644 index 0000000..9bce78f Binary files /dev/null and b/assets/models/blender_src/sunglasses.blend differ diff --git a/assets/models/blender_src/traffic_cone.blend b/assets/models/blender_src/traffic_cone.blend new file mode 100644 index 0000000..a1a29ce Binary files /dev/null and b/assets/models/blender_src/traffic_cone.blend differ diff --git a/assets/models/blender_src/trash_bin.blend b/assets/models/blender_src/trash_bin.blend new file mode 100644 index 0000000..e44e60b Binary files /dev/null and b/assets/models/blender_src/trash_bin.blend differ diff --git a/assets/models/blender_src/umbrella.blend b/assets/models/blender_src/umbrella.blend new file mode 100644 index 0000000..7ca7373 Binary files /dev/null and b/assets/models/blender_src/umbrella.blend differ diff --git a/assets/models/blender_src/wall_lantern.blend b/assets/models/blender_src/wall_lantern.blend new file mode 100644 index 0000000..2c28f1a Binary files /dev/null and b/assets/models/blender_src/wall_lantern.blend differ diff --git a/assets/models/blender_src/wand.blend b/assets/models/blender_src/wand.blend new file mode 100644 index 0000000..e61a614 Binary files /dev/null and b/assets/models/blender_src/wand.blend differ diff --git a/docs/architecture.md b/docs/architecture.md index 5cd2ae9..a5d1a62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,7 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st - `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback. - `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases. - `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). +- `tools/blender_assets.py` builds, renders and audits the staged Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. Not loaded at runtime yet (see `docs/blender-assets.md`). - `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures. Confirmed game assets live in `assets/textures/ai/game_ready/` (audited surface, UI, and card textures), `assets/models/generated/` (prop and NPC `.glb` models plus their texture atlases), and `assets/textures/npc/` (NPC material maps and `palette.json`). Development-only outputs — AI concept sheets, Blender sources, raw AI fabrics — are gitignored and regenerated by the tools. diff --git a/docs/blender-assets.md b/docs/blender-assets.md new file mode 100644 index 0000000..1520252 --- /dev/null +++ b/docs/blender-assets.md @@ -0,0 +1,182 @@ +# Blender asset library + +Status: built and audited (2026-11-01). Nothing in the game loads these assets yet. +They are staged for the Phase 3.5 integration in +[`plan-graphics-v0.6.md`](plan-graphics-v0.6.md). + +Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the +source of truth for integration): +[`assets/models/blender/manifest.json`](../assets/models/blender/manifest.json). + +## Art direction + +The references were the AI mockups in `docs/screens/designs/` and the current +captures in `docs/screenshots/`. + +- **People** (`targeting_npc`, `joke_shop_queue`, `studio_arrival`, + `crowd_panic`, `responder_arrival`): ordinary adults in sweaters, jackets, + long coats, hoodies and beanies; a security guard in a dark uniform with a cap + and belt; a firefighter in a dark turnout coat with yellow reflective bands, + a helmet and an SCBA tank. The current skinned `npc_base` is a set of boxes + and cylinders with a flat face, so silhouette was the main gap. +- **Props** (`prop_gallery`, `venue_overview`, `late_night_avenue`, + `joke_shop_queue`): the gallery mockup has an octagonal tapered bollard, a + slatted bench with thin metal legs, an octagonal green litter bin with a slot + and recessed panels, a stone planter with a lumpy shrub, a blue news box with + a window and a slanted top, and a hydrant with caps and nozzles. The venue + shots add ornate street lamps, palm trees, round stone pots with topiary, a + chalkboard A-frame sign, a wall lantern, a bulb-lit marquee, glazed double + doors, stair handrails and a dark sedan. Today's in-game props are primitive + boxes, and the trees are cones. + +## Running it + +```bash +# Build every asset (or --only a,b,c). Writes GLBs, .blend sources, manifest. +flatpak run org.blender.Blender --background \ + --python "$PWD/tools/blender_assets.py" -- build +# Cycles previews (CPU, ~3 s each) -> docs/blender_previews/.png (gitignored) +flatpak run org.blender.Blender --background \ + --python "$PWD/tools/blender_assets.py" -- render +python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_.jpg +python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue +python3 tools/blender_assets.py list +godot --headless --path . --import # after any rebuild +``` + +The flatpak cannot read `/tmp` and its cwd is `/app/blender`, so the script +derives every path from its own location. A full build takes about 5 s; a full +render about 2.5 min. + +## Layout + +| Kind | Path | Count | +| --- | --- | --- | +| Characters (skinned) | `assets/models/blender/npc/.glb` | 8 | +| Gear (static) | `assets/models/blender/npc_gear/.glb` | 18 | +| Props (static) | `assets/models/blender/props/.glb` | 20 | +| Editable sources | `assets/models/blender_src/.blend` (`.gdignore`d) | 46 | +| Review sheets | `docs/blender_previews/sheet_{npc,npc_gear,props}.jpg` | 3 | + +## Conventions + +- Blender is Z-up. The glTF exporter converts to Godot Y-up. +- **Front = Blender −Y = Godot +Z**, the face direction that + `NpcAvatar.yaw_towards()` assumes. +- Grounded assets have their origin on the floor (`min y ≈ 0`). The two + exceptions are `wall_lantern` (origin on the wall plate, at mount height) and + `marquee` (origin at the wall where the canopy meets it, at canopy height). +- Units are metres, at the same scale as the current world (1.82 m characters). + +## Characters + +All eight use the `npc_base` contract, so each one can replace +`npc_base.glb` without code changes: + +- mesh node `npc_base` under an armature that imports as `Skeleton3D` +- bones `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R` + (plus `Hips`, `Spine`, `Neck`) at the **same rest positions as `npc_base`**. + `test_character_joints_line_up_with_npc_base` checks this within 1 cm, so the + animation proxies in `_build_skinned_body()` stay valid. +- material slots `skin`, `cloth`, `pants`, `accessory`, `hair` carry the same + neutral `assets/textures/npc/_{albedo,normal}.png` maps, so + `Materials.npc_slot()` tints them the same way it tints `npc_base`. +- budget ≤ 6000 triangles and ≤ 4000 vertices (glTF vertex count, after seam + splits). The current range is 3,788–4,640 triangles and 3,200–3,960 + vertices. + +Construction: the torso and limbs are one continuous Skin-modifier mesh, +subdivided twice, decimated to 36 % with X symmetry, and cut at the waist, cuffs, +sleeves and neck so the material borders are clean edge loops. The head, +face (nose, ears, eyes, brows, mouth), hair, hands, shoes and clothing layers are +separate parts. Weights come from distance to the bone segments, limited by body +region. Parts that should stay rigid (head, hands, shoes) get a single bone. + +| Character | `layer_for()` / look | Suggested roles | Preview gear | +| --- | --- | --- | --- | +| `npc_casual` | `none`: sweater/tee, short sleeves | tourist, confused visitor, intoxicated, guest | backpack, camera | +| `npc_jacket` | `jacket`: collar + placket | authorized staff, protester, guest | lanyard, clipboard | +| `npc_coat` | `coat`: knee-length coat, long hair | influencer, trespasser, violent threat | sunglasses, shoulder bag, phone | +| `npc_vest` | `vest`: hi-vis vest, reflective bands | delivery | cap, clipboard | +| `npc_dress` | `dress`: slim build, bun, tights | performer | shoulder bag | +| `npc_hoodie` | `hood` accessory: hood up, beard, pocket | trespasser, mental crisis, violent threat | cup | +| `npc_firefighter` | responder: broad build, banded turnout coat, boots | responder | helmet, SCBA tank, flashlight | +| `npc_guard` | uniform: belt, buckle, badge, shoulder patches, radio | player guard / venue security | cap, wand | + +**Fixed-colour materials.** Detail materials outside the five slots (`hivis`, +`reflective`, `leather`, `steel`, `patch`, `plastic_black`, `rubber`) carry a 4×4 +colour texture. The current `_apply_outfit_materials()` sets +`albedo_color = WHITE` on any slot it does not recognise, and these keep their +colour through that. When integrating, tint only the five contract slots. + +**Runtime tint notes.** `_build_skinned_body()` currently paints `cloth` +hi-vis yellow for the `vest` layer. On `npc_vest` the vest is already the fixed +`hivis` material and `cloth` is the undershirt, so skip that override for this +model. For `npc_firefighter` and `npc_guard`, tint `cloth`/`pants` dark (turnout / +uniform navy) and not with the role's `TYPE_COLORS`. + +## Gear attach points + +Gear is authored in attach-point space, so the origin of each GLB is its +attach point: + +| Attach | Godot parent / position | Gear | +| --- | --- | --- | +| `head` | `Rig/Head` marker (0, 1.62, 0) | cap, beanie, hood, sunglasses, firefighter_helmet | +| `back` | `Rig` at (0, 1.10, 0), spine centre; items sit behind the body (Blender +Y = Godot −Z) | backpack, shoulder_bag, scba_tank | +| `chest` | `Rig` at (0, 1.30, 0), sternum | lanyard | +| `hand` | Hand proxy (±0.27, 0.78, 0.02); grip 5 cm below the origin | phone, cup, clipboard, umbrella, camera, protest_sign, flashlight, wand, radio | + +The character previews in `sheet_npc.jpg` are built by importing the gear at +exactly these points, which gives a quick visual check of the convention. + +## Props + +| Prop | Replaces / role | Notes | +| --- | --- | --- | +| `bollard`, `bench`, `trash_bin`, `planter_box`, `newspaper_box`, `fire_hydrant` | `assets/models/generated/.glb` | same footprint and height as the generated set, so colliders and `PROP_PLACEMENTS` still fit | +| `street_lamp` | the box lamps in `_build_street_furniture()` | 5 m; the arm points to −X with the bulb at (−0.85, 4.8, 0). Rotate 180° for the other side of the street. Emissive `bulb` | +| `street_tree` | the cone trees | 3.6 m, with a tree grate | +| `traffic_cone` | the `cone` prop accessory | 0.69 m | +| `palm_tree` | new (boulevard) | 7 m, 14 fronds | +| `round_planter` | new (mockup pots) | stone pot with topiary | +| `a_frame_sign` | new (comedy club queue) | chalkboard | +| `wall_lantern` | new (club door) | wall-mounted; emissive `bulb` | +| `crowd_barrier`, `stanchion` | new (queue / crowd control) | 2.2 m bike-rack barricade; chrome post with velvet rope | +| `handrail` | new (steps) | 2 m run that climbs 0.8 m along +X | +| `security_podium` | new (door check-in) | tablet screen is mildly emissive | +| `double_door` | the procedural entrance door | 2.0 × 2.7 m; emissive `lock_led` on the card reader | +| `marquee` | the procedural marquee | 6 m canopy and sign box; emissive `bulb` + `sign_face`. Overlay the text with the existing `Label3D` | +| `sedan` | new (studio arrival) | 4.6 m; emissive `headlight`, red tail lamps | + +Props use plain PBR factors (base colour, roughness, metallic, emission) and +have no textures. They are not yet AO-baked; that is still a Phase 4 task. +Emissive materials are always on in the GLB. The integration should tie them to +`WorldBuilder.set_night_mode()`. + +## Checks + +- `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/ + `TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them + during LOD generation), budgets (characters 6000/4000, gear 1500, props 4000 + triangles), grounding, and an editable `.blend`. For characters it also + checks skinning, bones, slots, the `npc_base` mesh node and the height. +- `tools/test_blender_assets.py`: catalogue and contract consistency with + `npc_pipeline.py`, plus a clean audit and full manifest coverage when the + library is built. +- `src/test/tests/test_blender_assets.gd`: every asset imports as a scene + with geometry, characters keep their bones and slots, joints match + `npc_base`, the bones accept `NpcAvatar`'s pose composition, and props are + grounded. + +## Known limitations + +- The characters have no baked AO or ORM texture of their own. They reuse the + tiling slot maps, so the runtime `SurfaceMaterials` recipes still supply + roughness. +- There is a single body topology per build (average, slim, broad) and no + shape keys. Height variety still comes from `outfit_for()["height_scale"]`. +- Skirts and coats follow the hip and knee bones, so a full stride can clip + the legs through the hem. +- Preview colours (`PREVIEW_TINTS`) only stand in for `outfit_for()`. The GLBs + stay neutral. diff --git a/docs/blender_previews/.gdignore b/docs/blender_previews/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/blender_previews/sheet_npc.jpg b/docs/blender_previews/sheet_npc.jpg new file mode 100644 index 0000000..2cc2e50 Binary files /dev/null and b/docs/blender_previews/sheet_npc.jpg differ diff --git a/docs/blender_previews/sheet_npc_gear.jpg b/docs/blender_previews/sheet_npc_gear.jpg new file mode 100644 index 0000000..0b6f156 Binary files /dev/null and b/docs/blender_previews/sheet_npc_gear.jpg differ diff --git a/docs/blender_previews/sheet_props.jpg b/docs/blender_previews/sheet_props.jpg new file mode 100644 index 0000000..bc7aaa0 Binary files /dev/null and b/docs/blender_previews/sheet_props.jpg differ diff --git a/docs/plan-graphics-v0.6.md b/docs/plan-graphics-v0.6.md index 9a3f69a..57c1f2b 100644 --- a/docs/plan-graphics-v0.6.md +++ b/docs/plan-graphics-v0.6.md @@ -168,7 +168,7 @@ Outputs land under `assets/textures/ai/game_ready/` (or a new The library generator is `tools/blender_assets.py` (flatpak Blender, headless). It stages outputs for integration; nothing is wired into the game until the -audit and manifest are clean. While the generator is running, do not edit it. +audit and manifest are clean. Integration notes: [`blender-assets.md`](blender-assets.md). **Outputs and contract** @@ -176,10 +176,10 @@ audit and manifest are clean. While the generator is running, do not edit it. | --- | --- | --- | | Characters | `assets/models/blender/npc/.glb` | 8 variants: `npc_casual`, `npc_jacket`, `npc_coat`, `npc_vest`, `npc_dress`, `npc_hoodie`, `npc_firefighter`, `npc_guard` | | Gear | `assets/models/blender/npc_gear/.glb` | 18 pieces; authored in attach-point space (`head`, `hand`, `back`, `chest`) | -| Props | `assets/models/blender/props/.glb` | 22 pieces; the first nine replace current generated/primitive street furniture, the rest are new venue dressing | +| Props | `assets/models/blender/props/.glb` | 20 pieces; the first nine replace current generated/primitive street furniture, the rest are new venue dressing | | Manifest | `assets/models/blender/manifest.json` | path, category, triangles, vertices, bounds, materials, roles, tint slots, attach points | | Sources | `assets/models/blender_src/.blend` | committed, editable source | -| Previews | `docs/blender_previews/*.png` + `sheet_.png` | `render` / `sheet` for visual review | +| Previews | `docs/blender_previews/sheet_.jpg` (committed) + per-asset `*.png` (local) | `render` / `sheet` for visual review | All characters reuse the `npc_base` contract: mesh node `npc_base`, bones `Head/HipL/R/KneeL/R/ShoulderL/R/ElbowL/R/HandL/R` (+ Hips/Spine/Neck), @@ -191,10 +191,9 @@ Props are grounded except `wall_lantern` and `marquee`; both may be emissive. **Integration steps** 1. **Audit the finished build.** `python3 tools/blender_assets.py audit` and - `sheet`; fix assets or reconcile the budgets. The first `npc_casual` result - is 7,756 tris / 5,616 verts, above the current 6,000 / 4,000 character - contract — either simplify the mesh or explicitly raise and document the - character budget before wiring variants in. + `sheet`; fix assets or reconcile the budgets. **Done 2026-11-01:** the + characters were simplified to fit the unchanged 6,000 / 4,000 contract + (3,788–4,640 tris), and all 46 assets audit clean. 2. **Commit the library and sources.** Add `assets/models/blender/**`, `assets/models/blender_src/**`, the generator, manifest, previews, and `docs/blender-assets.md`; generated folders that CI can rebuild offline may diff --git a/src/test/tests/test_blender_assets.gd b/src/test/tests/test_blender_assets.gd new file mode 100644 index 0000000..112afed --- /dev/null +++ b/src/test/tests/test_blender_assets.gd @@ -0,0 +1,150 @@ +extends RefCounted +## Headless checks for the Blender-authored assets staged under +## `assets/models/blender/` (tools/blender_assets.py). Nothing in the game loads +## them yet; these tests keep them import-clean and drop-in compatible with the +## npc_base contract until they are integrated. + +const MANIFEST := "res://assets/models/blender/manifest.json" +const NPC_BASE := "res://assets/models/generated/npc_base.glb" +const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"] +const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", + "ElbowL", "ElbowR", "HandL", "HandR"] + +func _manifest() -> Dictionary: + if not FileAccess.file_exists(MANIFEST): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST)) + return parsed if parsed is Dictionary else {} + +func _assets(category: String = "") -> Dictionary: + var out := {} + var assets: Dictionary = _manifest().get("assets", {}) + for name in assets: + var entry: Dictionary = assets[name] + if category == "" or str(entry.get("category", "")) == category: + out[name] = entry + return out + +func _instance(entry: Dictionary) -> Node3D: + var packed := ResourceLoader.load("res://" + str(entry["path"])) as PackedScene + if packed == null: + return null + return packed.instantiate() as Node3D + +func _global_rest(skeleton: Skeleton3D, bone: int) -> Transform3D: + var xf := skeleton.get_bone_rest(bone) + var parent := skeleton.get_bone_parent(bone) + while parent >= 0: + xf = skeleton.get_bone_rest(parent) * xf + parent = skeleton.get_bone_parent(parent) + return xf + +func test_manifest_lists_every_category() -> bool: + var counts := {} + for entry in _assets().values(): + var category := str(entry.get("category", "")) + counts[category] = int(counts.get(category, 0)) + 1 + return TestUtils.check(int(counts.get("npc", 0)) >= 8 + and int(counts.get("npc_gear", 0)) >= 10 and int(counts.get("props", 0)) >= 10, + "manifest lists characters, gear and props (%s)" % str(counts)) + +func test_every_asset_imports_as_a_scene_with_geometry() -> bool: + var problems: Array[String] = [] + var assets := _assets() + for name in assets: + var node := _instance(assets[name]) + if node == null: + problems.append("%s not imported" % name) + continue + if node.find_children("*", "MeshInstance3D", true, false).is_empty(): + problems.append("%s has no mesh" % name) + node.free() + return TestUtils.check(not assets.is_empty() and problems.is_empty(), + "Blender assets import (%s)" % str(problems)) + +func test_characters_match_the_npc_base_contract() -> bool: + var problems: Array[String] = [] + var assets := _assets("npc") + for name in assets: + var node := _instance(assets[name]) + if node == null: + problems.append("%s missing" % name) + continue + var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D + var mesh := node.find_child("npc_base", true, false) as MeshInstance3D + if skeleton == null or mesh == null: + problems.append("%s lacks Skeleton3D/npc_base" % name) + node.free() + continue + for bone in BONES: + if skeleton.find_bone(bone) < 0: + problems.append("%s missing bone %s" % [name, bone]) + var slots := {} + for i in mesh.mesh.get_surface_count(): + var mat := mesh.mesh.surface_get_material(i) + if mat != null: + slots[str(mat.resource_name)] = true + for slot in SLOTS: + if not slots.has(slot): + problems.append("%s missing slot %s" % [name, slot]) + node.free() + return TestUtils.check(assets.size() >= 8 and problems.is_empty(), + "characters keep bones + slots (%s)" % str(problems)) + +func test_character_joints_line_up_with_npc_base() -> bool: + ## NpcAvatar positions its animation proxies for npc_base; a drop-in + ## replacement must put the same joints in the same places. + if not ResourceLoader.exists(NPC_BASE): + return TestUtils.check(true, "npc_base not imported; skipped") + var base := (ResourceLoader.load(NPC_BASE) as PackedScene).instantiate() + var base_skel := base.find_child("Skeleton3D", true, false) as Skeleton3D + var problems: Array[String] = [] + var assets := _assets("npc") + for name in assets: + var node := _instance(assets[name]) + var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null + if skeleton == null: + problems.append("%s has no skeleton" % name) + else: + for bone in BONES: + var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin + var b := _global_rest(skeleton, skeleton.find_bone(bone)).origin + if a.distance_to(b) > 0.01: + problems.append("%s.%s off by %.3f" % [name, bone, a.distance_to(b)]) + if node: + node.free() + base.free() + return TestUtils.check(problems.is_empty(), "joints match npc_base (%s)" % str(problems)) + +func test_character_bones_accept_the_avatar_pose_math() -> bool: + ## Same composition as NpcAvatar._pose_bone(): rest * offset must stay a + ## valid rotation on every limb bone. + var problems: Array[String] = [] + var assets := _assets("npc") + for name in assets: + var node := _instance(assets[name]) + var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null + if skeleton == null: + problems.append(name) + else: + for bone in BONES: + var index := skeleton.find_bone(bone) + var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion() + skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4)) + if not skeleton.get_bone_pose_rotation(index).is_normalized(): + problems.append("%s.%s" % [name, bone]) + if node: + node.free() + return TestUtils.check(problems.is_empty(), "bones pose cleanly (%s)" % str(problems)) + +func test_grounded_props_sit_on_the_floor() -> bool: + var problems: Array[String] = [] + var assets := _assets("props") + for name in assets: + var bounds: Dictionary = assets[name].get("bounds_godot", {}) + if name in ["wall_lantern", "marquee"] or bounds.is_empty(): + continue + var min_y := float(bounds["min"][1]) + if absf(min_y) > 0.03: + problems.append("%s min y %.3f" % [name, min_y]) + return TestUtils.check(problems.is_empty(), "props grounded (%s)" % str(problems)) diff --git a/tools/blender_assets.py b/tools/blender_assets.py index feff420..8431fdf 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -9,12 +9,12 @@ integration: nothing in the game loads them yet (see # build every asset (or --only name,name) -> assets/models/blender/**.glb flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- build - # Cycles preview renders -> docs/blender_previews/.png + # Cycles preview renders -> docs/blender_previews/.png (local only) flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- render # outside Blender (stdlib + Pillow) python3 tools/blender_assets.py audit # GLB contract checks - python3 tools/blender_assets.py sheet # contact sheets from previews + python3 tools/blender_assets.py sheet # sheet_.jpg (committed) python3 tools/blender_assets.py list The flatpak sandbox cwd is /app/blender and it cannot read /tmp, so every path @@ -68,31 +68,38 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender # pick one per avatar later. `tint` lists the slots the runtime should tint; # every other material keeps its authored colour. CHARACTERS = { - "npc_casual": {"layer": "none", "hair": "short", + "npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"], "roles": ["tourist", "confused_visitor", "intoxicated", "guest"]}, - "npc_jacket": {"layer": "jacket", "hair": "short", + "npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"], "roles": ["authorized_staff", "protester", "guest"]}, "npc_coat": {"layer": "coat", "hair": "long", + "gear": ["sunglasses", "shoulder_bag", "phone"], "roles": ["influencer", "trespasser", "violent_threat"]}, - "npc_vest": {"layer": "vest", "hair": "buzz", + "npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"], "roles": ["delivery"]}, "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", + "gear": ["shoulder_bag"], "roles": ["performer"]}, - "npc_hoodie": {"layer": "hood", "hair": "short", + "npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "roles": ["trespasser", "mental_crisis", "violent_threat"]}, "npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad", + "gear": ["firefighter_helmet", "scba_tank", "flashlight"], "roles": ["responder"]}, "npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", + "gear": ["cap", "wand"], "roles": ["player_guard", "venue_security"]}, } # Gear origin = the attach point; offsets are in the attach node's local space. GEAR_ATTACH = { "head": "Rig/Head marker (y = 1.62 m, head centre)", - "hand": "Hand proxy (grip centre, item extends along -Y = down the forearm)", + "hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin", "back": "Rig root, origin at the spine centre (0, 1.10, 0)", "chest": "Rig root, origin at the sternum (0, 1.30, 0)", } +# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)). +ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78), + "back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)} GEAR = { "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "firefighter_helmet": "head", @@ -292,6 +299,7 @@ def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1): mod.limit_method = "ANGLE" mod.angle_limit = math.radians(40) _apply_all(obj) + _clean(obj) if smooth: obj.data.shade_smooth() obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle)) @@ -300,6 +308,16 @@ def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1): return obj +def _clean(obj): + """Drop zero-area faces (thin bevels, lathe poles) before export.""" + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges) + bm.to_mesh(obj.data) + bm.free() + + def _apply_all(obj): bpy.context.view_layer.objects.active = obj for o in bpy.context.selected_objects: @@ -347,14 +365,20 @@ def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6, return _finish(obj, mat, smooth=True, sharp_angle=70) -def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0): - """Revolve [(radius, z), ...] around Z (bottom to top), capped.""" +def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0, + face_front=False, caps=True): + """Revolve [(radius, z), ...] around Z (bottom to top), capped. + + `face_front` rotates the ring half a step so a flat face (not a corner) + points at the front (-Y); used for octagonal street furniture. + """ + phase = (math.pi / seg + math.pi / 2) if face_front else 0.0 bm = bmesh.new() rings = [] for r, z in profile: ring = [] for i in range(seg): - a = math.tau * i / seg + a = math.tau * i / seg + phase ring.append(bm.verts.new((loc[0] + r * math.cos(a), loc[1] + r * math.sin(a), loc[2] + z))) rings.append(ring) @@ -362,9 +386,9 @@ def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0): for i in range(seg): j = (i + 1) % seg bm.faces.new((a[i], a[j], b[j], b[i])) - if profile[0][0] > 1e-4: + if caps and profile[0][0] > 1e-4: bm.faces.new(list(reversed(rings[0]))) - if profile[-1][0] > 1e-4: + if caps and profile[-1][0] > 1e-4: bm.faces.new(rings[-1]) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) @@ -416,14 +440,14 @@ def join(objs, name): return obj -def uv_unwrap(obj): +def uv_unwrap(obj, angle=60.0): for o in bpy.context.selected_objects: o.select_set(False) obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") - bpy.ops.uv.smart_project(angle_limit=math.radians(60.0), island_margin=0.01) + bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01) bpy.ops.object.mode_set(mode="OBJECT") @@ -481,12 +505,12 @@ def _skin_body(spec): "guard": 0.03, "vest": 0.02}.get(layer, 0.0) sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22, "guard": 0.05}.get(layer, 0.0) - leg = 1.0 + {"responder": 0.2, "dress": -0.1}.get(layer, 0.0) + leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0) nodes = [ # (pos, radius_x, radius_y) ((0, 0, 0.80), 0.165 * w, 0.115 * d), # 0 pelvis ((0, 0, 0.96), 0.150 * w * bulk, 0.105 * d * bulk), # 1 waist ((0, 0.0, 1.18), 0.170 * w * bulk, 0.115 * d * bulk), # 2 chest - ((0, 0.0, 1.33), 0.175 * w * bulk, 0.110 * d * bulk), # 3 upper chest + ((0, 0.0, 1.355), 0.172 * w * bulk, 0.108 * d * bulk), # 3 upper chest ((0, 0, 1.44), 0.058, 0.058), # 4 neck base ((0, -0.005, 1.53), 0.052, 0.052), # 5 neck top ] @@ -494,7 +518,7 @@ def _skin_body(spec): for s in (-1.0, 1.0): base = len(nodes) nodes += [ - ((0.20 * s * w, 0, 1.37), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder + ((0.20 * s * w, 0, 1.39), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder ((0.27 * s * w, 0, 1.24), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm ((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow ((0.275 * s * w, -0.01, 0.80), 0.034 * t, 0.030 * t), # wrist @@ -521,7 +545,7 @@ def _skin_body(spec): sub = obj.modifiers.new("Sub", "SUBSURF") sub.levels = 2 dec = obj.modifiers.new("Decimate", "DECIMATE") - dec.ratio = 0.5 + dec.ratio = 0.36 dec.use_symmetry = True dec.symmetry_axis = "X" _apply_all(obj) @@ -531,6 +555,9 @@ def _skin_body(spec): for z in (0.86, 1.19, 1.43): geom = bm.verts[:] + bm.edges[:] + bm.faces[:] bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1)) + # Cuts next to existing vertices leave slivers; weld them. + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4) + bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges) bm.to_mesh(obj.data) bm.free() # Front of the chest/backside slightly shaped: push chest forward a touch. @@ -552,10 +579,10 @@ def _assign_body_slots(obj, spec, mats): obj.data.materials.append(m) idx = {n: i for i, n in enumerate(names)} short_sleeve = layer in ("none", "vest", "dress") - top_slot = {"vest": "undershirt", "responder": "turnout", - "guard": "cloth"}.get(layer, "cloth") - leg_slot = {"responder": "turnout", "dress": "skin"}.get(layer, "pants") - waist = 0.86 if layer != "dress" else 0.0 + # Main surfaces always use the tintable contract slots (dress legs = tights). + top_slot = "cloth" + leg_slot = "pants" + waist = 0.86 for poly in obj.data.polygons: c = poly.center arm = abs(c.x) > 0.205 and c.z > 0.78 @@ -648,6 +675,32 @@ def _hair(style, mat_hair, broad=False): return parts +def _hood(name, mat, head_center): + """Hood shell around the head with an oval face opening and a solid rim.""" + cx, cy, cz = head_center + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19) + bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0), + (0.97, 1.05, 1.06)), verts=bm.verts) + doomed = [v for v in bm.verts + if v.co.z < cz - 0.17 + or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2 + + ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)] + bmesh.ops.delete(bm, geom=doomed, context="VERTS") + # Snap the stair-stepped opening onto the ellipse for a clean edge. + for v in bm.verts: + if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16: + dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165 + k = math.hypot(dx, dz) + if k > 1e-6: + v.co.x = cx + dx / k * 0.125 + v.co.z = cz - 0.01 + dz / k * 0.165 + # Thicken into a shell so the open edge reads as fabric, not a paper cut. + bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60) + + def _face(mats, spec): skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12, @@ -666,7 +719,7 @@ def _face(mats, spec): rot=(0, 0.12 * s, 0.0))) parts.append(box("Mouth", (0.06, 0.012, 0.01), (0, -0.142, 1.585), acc)) if spec.get("beard"): - parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=16, rings=10, + parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8, scale=(1.0, 0.9, 0.75), cut_below=-0.2)) return parts @@ -678,12 +731,12 @@ def _hands_and_shoes(mats, spec): w = BUILDS[spec.get("build", "average")][0] for s in (-1.0, 1.0): x = 0.275 * s * w - parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=12, rings=8, + parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6, scale=(0.55, 0.95, 1.25))) parts.append(sphere("Thumb", 0.018, (x - 0.01 * s, -0.045, 0.75), skin, seg=8, rings=6, scale=(1.0, 1.0, 1.8), rot=(0.4, 0, 0))) parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe, - bevel=0.028, segs=2)) + bevel=0.028, segs=1)) parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01), material("rubber", textured_solid=True))) return parts @@ -696,15 +749,24 @@ def _clothing(spec, mats): parts = [] if layer in ("coat", "dress", "responder"): bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer] - flare = {"coat": 0.215, "dress": 0.26, "responder": 0.21}[layer] - mat = cloth if layer != "responder" else mats["turnout"] - parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.92, bottom + 0.1), - (0.185, 0.80), (0.16, 0.93)], mat, seg=20)) + flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer] + mat = cloth + top = 0.17 if layer != "responder" else 0.20 + parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1), + (top + 0.03, 0.80), (top, 0.93)], mat, seg=16, + caps=False)) + # Double-sided shell: an inner copy with flipped normals, so the open + # hem does not vanish under Godot's back-face culling. + bm = bmesh.new() + bm.from_mesh(parts[-1].data) + bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012) + bm.to_mesh(parts[-1].data) + bm.free() for o in parts[-1:]: for v in o.data.vertices: # flatten front/back - v.co.y *= 0.78 + v.co.y *= 0.8 if layer in ("coat", "jacket", "guard", "responder"): - mat = cloth if layer != "responder" else mats["turnout"] + mat = cloth parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat, rot=(math.radians(8), 0, 0), seg=16)) parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc)) @@ -724,29 +786,18 @@ def _clothing(spec, mats): parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl, rot=(0, 0.12 * s, 0))) if layer == "hood": - parts.append(sphere("Hood", 0.20, (0, 0.03, 1.66), cloth, seg=20, rings=12, - scale=(0.95, 1.05, 1.05), cut_below=-0.55)) - # open the face: drop front-facing verts of the hood - hood = parts[-1] - bm = bmesh.new() - bm.from_mesh(hood.data) - front = [v for v in bm.verts if v.co.y < -0.08 and v.co.z < 1.79 and v.co.z > 1.50] - bmesh.ops.delete(bm, geom=front, context="VERTS") - bm.to_mesh(hood.data) - bm.free() - parts.append(torus("HoodRim", 0.13, 0.022, (0, -0.075, 1.645), cloth, - rot=(math.radians(78), 0, 0), scale=(0.95, 1.0, 1.2))) - parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth, bevel=0.01)) + parts.append(_hood("Hood", cloth, (0, 0, 1.635))) + parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth)) for s in (-1.0, 1.0): parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38), acc, seg=5)) if layer == "responder": refl = mats["reflective"] hv = mats["hivis"] - for z in (0.62, 1.00): - band = torus("Band", 0.20, 0.016, (0, 0, z), hv, seg=24, minor_seg=4) + for z, r in ((0.60, 0.252), (1.16, 0.205)): + band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4) for v in band.data.vertices: - v.co.y *= 0.78 + v.co.y *= 0.8 if z < 1.0 else 0.66 parts.append(band) for s in (-1.0, 1.0): parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl, @@ -764,7 +815,7 @@ def _clothing(spec, mats): parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28), mats["patch"], bevel=0.005)) parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84), - mats["leather"], bevel=0.01)) + mats["leather"])) parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30), mats["plastic_black"], bevel=0.008)) return parts @@ -775,14 +826,13 @@ def build_character(name): if name == "npc_hoodie": spec["beard"] = True mats = {slot: material(slot) for slot in SLOTS} - for extra in ("undershirt", "turnout", "hivis", "reflective", "leather", + for extra in ("hivis", "reflective", "leather", "steel", "patch", "plastic_black", "rubber", "helmet"): mats[extra] = material(extra, textured_solid=True) layout = _bone_layout() body = _skin_body(spec) - body_mats = [mats["skin"], mats["cloth"], mats["pants"], mats["undershirt"], - mats["turnout"]] + body_mats = [mats["skin"], mats["cloth"], mats["pants"]] _assign_body_slots(body, spec, body_mats) _auto_weights(body, layout) @@ -819,15 +869,14 @@ def build_character(name): _auto_weights(cl, layout, rigid=cloth_bone) rest.append(cl) - for o in rest: - print(f" part {o.name}: {len(o.data.polygons)} faces {len(o.data.vertices)} verts") mesh = join(rest, "npc_base") # Every contract slot must exist even if a variant does not paint with it. present = {m.name for m in mesh.data.materials if m} for slot in SLOTS: if slot not in present: mesh.data.materials.append(mats[slot]) - uv_unwrap(mesh) + # Fewer, larger islands keep the seam-split vertex count inside the budget. + uv_unwrap(mesh, angle=80.0) arm_data = bpy.data.armatures.new("Armature") arm = bpy.data.objects.new("Armature", arm_data) @@ -870,20 +919,14 @@ def build_gear(name): scale=(1.0, 1.04, 1.02), cut_below=-0.05)) p.append(torus("Cuff", 0.163, 0.03, (0, 0.01, 0.04), M("plastic_grey"), seg=20)) elif name == "hood": - p.append(sphere("Hood", 0.20, (0, 0.03, 0.03), M("plastic_grey"), seg=20, - rings=12, cut_below=-0.55)) - hood = p[-1] - bm = bmesh.new() - bm.from_mesh(hood.data) - bmesh.ops.delete(bm, geom=[v for v in bm.verts if v.co.y < -0.08 and -0.12 < v.co.z < 0.16], - context="VERTS") - bm.to_mesh(hood.data) - bm.free() + p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015))) elif name == "sunglasses": for s in (-1.0, 1.0): p.append(cyl("Lens", 0.03, 0.03, 0.008, (0.05 * s, -0.152, 0.035), M("lens"), seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0))) - p.append(box("Arm", (0.006, 0.15, 0.008), (0.135 * s, -0.08, 0.04), + p.append(box("Hinge", (0.06, 0.008, 0.01), (0.11 * s, -0.150, 0.045), + M("plastic_black"))) + p.append(box("Arm", (0.006, 0.15, 0.008), (0.138 * s, -0.078, 0.042), M("plastic_black"))) p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black"))) elif name == "firefighter_helmet": @@ -1003,6 +1046,8 @@ def build_gear(name): else: raise KeyError(name) obj = join(p, name) + if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower + obj.data.transform(Matrix.Translation((0, 0, -0.05))) uv_unwrap(obj) return [obj] @@ -1052,16 +1097,18 @@ def build_prop(name): (x, 0.24, 0.62)], 0.016, M("black_metal"), seg=8)) elif name == "trash_bin": # octagonal, r ~0.28, h 0.9 p.append(lathe("Base", [(0.27, 0.0), (0.27, 0.08), (0.25, 0.10)], M("bin_green"), - seg=8, smooth=False)) + seg=8, smooth=False, face_front=True)) p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8, - smooth=False)) + smooth=False, face_front=True)) p.append(lathe("Lid", [(0.0, 0.78), (0.285, 0.78), (0.285, 0.84), (0.24, 0.90), - (0.0, 0.92)], M("bin_green"), seg=8, smooth=False)) - p.append(box("Slot", (0.30, 0.03, 0.10), (0, -0.255, 0.70), M("plastic_black"))) - for a in range(8): # recessed panels - ang = math.tau * (a + 0.5) / 8 - p.append(box("Panel", (0.17, 0.012, 0.42), - (math.sin(ang) * 0.248, -math.cos(ang) * 0.248, 0.38), + (0.0, 0.92)], M("bin_green"), seg=8, smooth=False, + face_front=True)) + p.append(box("Slot", (0.26, 0.03, 0.09), (0, -0.258, 0.70), M("plastic_black"))) + apothem = 0.255 * math.cos(math.pi / 8) + for a in range(8): # inset panels, one per face + ang = math.tau * a / 8 + p.append(box("Panel", (0.15, 0.01, 0.40), + (math.sin(ang) * apothem, -math.cos(ang) * apothem, 0.38), M("dark_metal"), rot=(0, 0, ang))) elif name == "planter_box": # 0.96 square, 0.54 high + shrub p.append(box("Planter", (0.96, 0.96, 0.50), (0, 0, 0.25), M("stone_pot"), @@ -1127,8 +1174,8 @@ def build_prop(name): p.append(trunk) top = (0.29, 0.0, 6.6) p.append(sphere("Crown", 0.24, top, M("palm_bark"), seg=10, rings=6)) - for i in range(11): - a = math.tau * i / 11 + for i in range(14): + a = math.tau * i / 14 droop = 0.6 + 0.35 * (i % 3) / 2 tip = (top[0] + math.cos(a) * 2.0, top[1] + math.sin(a) * 2.0, top[2] - droop * 1.1) @@ -1136,7 +1183,7 @@ def build_prop(name): bm = bmesh.new() pts = [Vector(top), Vector(mid), Vector(tip)] side = Vector((-math.sin(a), math.cos(a), 0)) - widths = [0.05, 0.38, 0.0] + widths = [0.06, 0.45, 0.0] vs = [] for pt, wd in zip(pts, widths): vs.append((bm.verts.new(pt - side * wd + Vector((0, 0, -0.06))), @@ -1172,7 +1219,7 @@ def build_prop(name): p.append(tube("Arm", [(0, -0.01, 0.05), (0, -0.14, 0.10), (0, -0.22, 0.08)], 0.015, M("black_metal"), seg=6)) p.append(lathe("Cap", [(0.0, 0.16), (0.05, 0.15), (0.11, 0.09), (0.11, 0.07)], - M("black_metal"), seg=8, loc=(0, -0.24, -0.12))) + M("black_metal"), seg=8, loc=(0, -0.24, 0.0))) p.append(lathe("Glass", [(0.08, -0.12), (0.10, 0.07)], M("bulb"), seg=8, loc=(0, -0.24, 0.0), smooth=False)) p.append(lathe("Bottom", [(0.0, -0.17), (0.09, -0.15), (0.09, -0.12)], @@ -1365,6 +1412,45 @@ def _setup_render(objs, out_path, character=False): scene.render.filepath = str(out_path) +# Preview-only tints (sRGB) standing in for NpcAvatar.outfit_for(); the GLBs +# keep neutral slot textures so the runtime tint stays authoritative. +PREVIEW_TINTS = { + "npc_casual": {"cloth": (34, 40, 62), "pants": (150, 120, 84)}, + "npc_jacket": {"cloth": (52, 70, 58), "pants": (40, 46, 60)}, + "npc_coat": {"cloth": (150, 112, 74), "pants": (30, 32, 38)}, + "npc_vest": {"cloth": (70, 74, 82), "pants": (44, 50, 64)}, + "npc_dress": {"cloth": (150, 30, 44), "pants": (30, 28, 30)}, + "npc_hoodie": {"cloth": (78, 80, 86), "pants": (34, 38, 48)}, + "npc_firefighter": {"cloth": (40, 42, 46), "pants": (40, 42, 46)}, + "npc_guard": {"cloth": (30, 34, 46), "pants": (30, 34, 46)}, +} +DEFAULT_TINTS = {"skin": (214, 170, 136), "hair": (58, 40, 28), "accessory": (30, 30, 34)} + + +def _tint_preview(name): + tints = dict(DEFAULT_TINTS) + tints.update(PREVIEW_TINTS.get(name, {})) + for mat in bpy.data.materials: + slot = mat.name.split(".")[0] + if slot not in tints or not mat.node_tree: + continue + nodes = mat.node_tree.nodes + bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None) + if bsdf is None or not bsdf.inputs["Base Color"].links: + continue + src = bsdf.inputs["Base Color"].links[0].from_socket + mix = nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = "MULTIPLY" + mix.inputs["Factor"].default_value = 1.0 + mat.node_tree.links.new(src, mix.inputs[6]) + rgb = tints[slot] + # neutral textures are light; lift the tint so the product lands near it + mix.inputs[7].default_value = tuple( + min(1.0, _srgb_to_linear(c / 255.0) * 1.5) for c in rgb) + (1.0,) + mat.node_tree.links.new(mix.outputs[2], bsdf.inputs["Base Color"]) + + def _render_one(category, name): path = asset_path(category, name) if not path.is_file(): @@ -1373,12 +1459,27 @@ def _render_one(category, name): reset_scene() bpy.ops.import_scene.gltf(filepath=str(path)) objs = list(bpy.context.scene.objects) + if category == "npc": + _tint_preview(name) + # Dress the preview with the recommended gear at the documented attach + # points; this also checks the attach-point convention visually. + for gear in CHARACTERS[name].get("gear", []): + gpath = asset_path("npc_gear", gear) + if not gpath.is_file(): + continue + before = set(bpy.context.scene.objects) + bpy.ops.import_scene.gltf(filepath=str(gpath)) + for o in set(bpy.context.scene.objects) - before: + if o.parent is None: + o.location = Vector(o.location) + Vector(ATTACH_BLENDER[GEAR[gear]]) + objs = list(bpy.context.scene.objects) if category == "npc_gear": # show gear at a readable scale on the floor mins_z = min((o.matrix_world @ Vector(c)).z for o in objs if o.type == "MESH" for c in o.bound_box) for o in objs: if o.parent is None: o.location.z -= mins_z + bpy.context.view_layer.update() PREVIEW_DIR.mkdir(parents=True, exist_ok=True) out = PREVIEW_DIR / f"{name}.png" _setup_render(objs, out, character=category == "npc") @@ -1412,6 +1513,22 @@ def _accessor(gltf, binary, index): for i in range(int(acc["count"]))] +def _degenerate(gltf, binary, pos_index, idx_index): + pos = _accessor(gltf, binary, pos_index) + idx = [i[0] for i in _accessor(gltf, binary, idx_index)] + count = 0 + for k in range(0, len(idx) - 2, 3): + a, b, c = pos[idx[k]], pos[idx[k + 1]], pos[idx[k + 2]] + u = (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + v = (c[0] - a[0], c[1] - a[1], c[2] - a[2]) + cx = u[1] * v[2] - u[2] * v[1] + cy = u[2] * v[0] - u[0] * v[2] + cz = u[0] * v[1] - u[1] * v[0] + if cx * cx + cy * cy + cz * cz < 1e-14: + count += 1 + return count + + def audit_asset(category, name): path = asset_path(category, name) res = {"name": name, "category": category, "ok": False, "issues": [], @@ -1428,7 +1545,7 @@ def audit_asset(category, name): res["issues"].append("bad GLB container") mins = [1e9] * 3 maxs = [-1e9] * 3 - tris = verts = 0 + tris = verts = degenerate = 0 for mesh in gltf.get("meshes", []): for prim in mesh.get("primitives", []): attrs = prim.get("attributes", {}) @@ -1448,7 +1565,13 @@ def audit_asset(category, name): break if prim.get("indices") is not None: tris += gltf["accessors"][prim["indices"]]["count"] // 3 + if "POSITION" in attrs: + degenerate += _degenerate(gltf, binary, attrs["POSITION"], + prim["indices"]) res["triangles"], res["vertices"] = tris, verts + if degenerate: + # Godot warns "non-finite normal in LOD generation" for these. + res["issues"].append(f"{degenerate} zero-area triangles") res["materials"] = [m.get("name", "") for m in gltf.get("materials", [])] if verts: res["bounds"] = {"min": [round(v, 3) for v in mins], @@ -1535,8 +1658,10 @@ def contact_sheets(): x, y = (i % cols) * cell, (i // cols) * (cell + 24) sheet.paste(im, (x, y)) draw.text((x + 8, y + cell + 5), n, fill=(230, 230, 230)) - out = PREVIEW_DIR / f"sheet_{cat}.png" - sheet.save(out) + # JPEG keeps the committed review sheets small; per-asset PNG renders + # stay local (gitignored) and are rebuilt with `render`. + out = PREVIEW_DIR / f"sheet_{cat}.jpg" + sheet.save(out, quality=86, optimize=True) written.append(out) print(f"wrote {out.relative_to(ROOT)}") return written diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py new file mode 100644 index 0000000..f93c37b --- /dev/null +++ b/tools/test_blender_assets.py @@ -0,0 +1,62 @@ +import json +import unittest + +import blender_assets as ba +import npc_pipeline + + +class CatalogueTests(unittest.TestCase): + def test_contract_matches_npc_pipeline(self): + self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS) + self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES)) + self.assertLessEqual(ba.CHAR_MAX_TRIS, npc_pipeline.MAX_TRIANGLES) + self.assertLessEqual(ba.CHAR_MAX_VERTS, npc_pipeline.MAX_VERTICES) + + def test_gear_uses_known_attach_points(self): + for name, attach in ba.GEAR.items(): + self.assertIn(attach, ba.GEAR_ATTACH, name) + self.assertIn(attach, ba.ATTACH_BLENDER, name) + + def test_character_gear_exists(self): + layers = set() + for name, spec in ba.CHARACTERS.items(): + layers.add(spec["layer"]) + for gear in spec.get("gear", []): + self.assertIn(gear, ba.GEAR, f"{name} -> {gear}") + # Every NpcAvatar.layer_for() value has a character. + for layer in ("none", "jacket", "coat", "vest", "dress"): + self.assertIn(layer, layers) + + def test_emissive_props_use_emissive_palette_entries(self): + for name, spec in ba.PROPS.items(): + for mat in spec.get("emissive", []): + self.assertGreater(ba.PALETTE[mat][3], 0.0, f"{name}:{mat}") + + +@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built") +class BuiltAssetTests(unittest.TestCase): + def test_every_asset_audits_clean(self): + bad = {} + for category, name in ba.catalogue(): + result = ba.audit_asset(category, name) + if not result["ok"]: + bad[name] = result["issues"] + self.assertEqual(bad, {}) + + def test_manifest_covers_catalogue(self): + manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) + self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()}) + + def test_manifest_paths_stay_in_the_library(self): + manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) + for name, entry in manifest["assets"].items(): + self.assertTrue(entry["path"].startswith("assets/models/blender/"), name) + if entry["category"] == "npc_gear": + self.assertIn(entry["attach"], ba.GEAR_ATTACH, name) + + def test_sources_are_hidden_from_godot(self): + self.assertTrue((ba.SRC_DIR / ".gdignore").is_file()) + + +if __name__ == "__main__": + unittest.main()