From 7efb5024ec5bdcd992371fc5bd096b02d55a21e5 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 1 Nov 2026 13:39:03 +0100 Subject: [PATCH] Extend the v0.6.0 plan with the Blender asset library Document the larger asset set produced by the running Blender generator (tools/blender_assets.py): 8 NPC character variants, 18 gear pieces, and 22 street/venue props with a manifest, editable sources, and preview sheets. - TODO gains Phase 3.5 with the audit/budget reconciliation (npc_casual is currently 7,756 tris / 5,616 verts, above the 6,000/4,000 contract), character selection by role, gear attach points, prop replacement/placement, emissive wiring, budgets, and screenshot gates - PLAN's Visual Quality Direction describes the library, runtime selection, and offline fallbacks - docs/plan-graphics-v0.6.md adds the library contract/output table and integration steps, and points Phase 4 at the Blender props - MEMORY records the manifest-as-source-of-truth rule and the generator caveat Generated library files stay untracked until the running build finishes and passes the audit; the generator itself is committed so the plan is reproducible. --- MEMORY.md | 9 + PLAN.md | 16 +- TODO.md | 61 +- docs/plan-graphics-v0.6.md | 100 ++- tools/blender_assets.py | 1600 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1766 insertions(+), 20 deletions(-) create mode 100644 tools/blender_assets.py diff --git a/MEMORY.md b/MEMORY.md index 7c4f747..6bcc966 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -129,6 +129,15 @@ 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. - **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 0eb50e2..b0a7a4e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -404,9 +404,19 @@ The studio also has authorized guests and staff. They must either be recognized ## Visual Quality Direction The game prioritizes readable, well-lit, low-poly presentation over realism (GL Compatibility -renderer). The next visual pass, v0.6.0, replaces the procedural NPC rig with Blender-authored, -UV-mapped, skinned characters carrying baked normal/AO/roughness maps, using the existing AI tools -for textures, palettes, and screen judging. See +renderer). The v0.6.0 visual pass moves presentation to a **Blender-authored asset library**: +skinned NPC character variants, head/back/chest/hand gear, and street/venue props, all carrying +baked albedo/normal/AO/ORM maps. A generator (`tools/blender_assets.py`) and manifest +(`assets/models/blender/manifest.json`) keep the assets reproducible; the game selects variants +by NPC role/outfit and keeps the procedural rig and primitive props as offline fallbacks. The +existing AI tools remain the source of art direction for fabric textures, palettes, and the +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 +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 [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) and [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md). diff --git a/TODO.md b/TODO.md index 14f40d3..f292b05 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,13 @@ 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. + ## Graphics quality — Blender-authored NPC characters (v0.6.0) Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md) @@ -58,19 +65,65 @@ 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) + +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 +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 + 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, + 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` / + `npc_firefighter` / `npc_guard`), with the existing `npc_base.glb` and procedural rig as + fallbacks; record the chosen path for tests. +- [ ] Keep `outfit_for()` tinting the `skin`/`cloth`/`pants`/`accessory`/`hair` slots on every + variant and keep `Rig/Head` as the focus/nametag anchor. +- [ ] Replace the procedural cap/hood/backpack/sunglasses/lanyard and hand items with the + matching `npc_gear` assets at the documented `head` / `back` / `chest` / `hand` attach + points; fall back to the current primitives when gear is missing. +- [ ] Load the replacement props (`bollard`, `bench`, `trash_bin`, `planter_box`, + `newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`, `traffic_cone`) from the + Blender library behind the existing `WorldBuilder` model/fallback path. +- [ ] Place the new street/venue props (`palm_tree`, `round_planter`, `a_frame_sign`, + `wall_lantern`, `crowd_barrier`, `stanchion`, `handrail`, `security_podium`, + `double_door`, `marquee`, `sedan`) through `PROP_PLACEMENTS`/dedicated venue slots, with + collider + `NavigationObstacle3D` footprints and no overlap with the door/approach lane. +- [ ] Wire the emissive parts (`street_lamp` bulb, `wall_lantern`, `double_door` lock LED, + `marquee` bulbs/sign face, `sedan` headlights) to the day/night phase lighting instead of + leaving them always on. +- [ ] Use the library `street_tree`/`palm_tree` instead of the current cone trees, and the + Blender `marquee`/`double_door` where the procedural versions currently sit; compare + silhouettes with `screen_designs.py`. +- [ ] Re-measure `smoke_npc_budget.gd` and add a prop draw/triangle budget check; keep + `MAX_LIVE_NPCS` unchanged until the new characters are measured. +- [ ] Regenerate all staged captures and run `screen_designs.py compare` + `judge`; add a + character close-up capture (the 02 target shot) to the catalogue if needed. + ### Phase 4 — Environment and lighting lift -- [ ] Bake AO for the prop set (or rebuild the props in Blender) behind the same `assets/models/generated/.glb` contract. -- [ ] Regenerate higher-resolution brick / concrete / asphalt textures via `asset_pipeline.py` with normal maps, keeping the current UV scales. +- [ ] Bake AO for the prop set through the Blender library (replacements above) or into the ORM/albedo maps behind the existing `WorldBuilder` contract; keep the procedural fallback. +- [ ] Regenerate higher-resolution brick / concrete / asphalt textures via `asset_pipeline.py` with normal maps, keeping the current UV scales; apply the same map discipline to the library's street materials. - [ ] Evaluate a desktop-only Forward+ export preset (SSAO/SSIL/volumetrics); keep the Web preset on GL Compatibility with `thread_support=false`. - [ ] Record the renderer decision in an ADR addendum or `MEMORY.md`. - [ ] Regenerate `docs/screenshots/` with `capture_screenshots.gd` and run `screen_designs.py compare`. ### Phase 5 — Verification and release -- [ ] Headless suite green: fallback build, outfit determinism, `Rig/Head` presence, material recipe assertions, `npc_pipeline.py audit` (skip with reason when Blender is absent). +- [ ] Headless suite green: fallback build, outfit determinism, `Rig/Head` presence, material recipe assertions, `npc_pipeline.py audit`, and `blender_assets.py audit` + manifest tests (skip Blender builds with a reason when Blender is absent). - [ ] Windowed smokes pass: `smoke_npc_budget.gd`, `smoke_movement.gd`, `smoke_focus_radial.gd`, `smoke_props.gd`. -- [ ] `screen_designs.py compare` and `judge` show no regressions. +- [ ] `screen_designs.py compare` and `judge` show no regressions; `blender_assets.py sheet` contact sheets reviewed for the character/gear/prop library. - [ ] Bump `config/version` to `0.6.0`. - [ ] Update `README.md`, `docs/architecture.md` (Level 2 gains `tools/npc_pipeline.py`), and mark ADR-0013 Accepted. - [ ] Publish the release per `docs/RELEASING.md`. diff --git a/docs/plan-graphics-v0.6.md b/docs/plan-graphics-v0.6.md index ba09d8e..9a3f69a 100644 --- a/docs/plan-graphics-v0.6.md +++ b/docs/plan-graphics-v0.6.md @@ -164,20 +164,89 @@ Outputs land under `assets/textures/ai/game_ready/` (or a new 6. Re-measure `smoke_npc_budget.gd`; adjust `LOD_TIERS` if the skinned rig is heavier up close. The impostor path is unchanged. +### Phase 3.5 — Blender asset library (characters, gear, props) + +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. + +**Outputs and contract** + +| Kind | Path | Notes | +| --- | --- | --- | +| 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 | +| 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 | + +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), +material slots `skin/cloth/pants/accessory/hair`, front toward Blender `-Y` +(Godot `+Z`), feet at `y ~= 0`. Gear origins are the attach points (head at +`Rig/Head`, hand at the hand proxy, back at the spine, chest at the sternum). +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. +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 + stay ignored. `blender_assets.py audit` must work without Blender. +3. **Select characters by role.** Extend `NpcAvatar` to pick a variant from the + manifest using the profile's role and `outfit_for`/`layer_for` (casual, + jacket, coat, vest, dress, hoodie, firefighter, guard). Fall back to + `npc_base.glb`, then to the procedural rig, and record the chosen path. +4. **Tint and anchor.** Apply `outfit_for` to every variant's five slots via + `SurfaceMaterials.npc_slot()`; keep `Rig/Head` for the focus circle, name tag, + reaction tag, and accessories. Keep the walk/idle/escalation bone-driving + wrapper. +5. **Replace procedural gear.** Use the `npc_gear` catalogue for caps, hoods, + helmets, backpacks, lanyards, hand items (phone, cup, clipboard, umbrella, + camera, sign, flashlight, wand, radio) and the SCBA tank; attach at the + documented points and fall back to the current primitives when an asset is + missing. +6. **Replace and extend props.** Load the replacement set through + `WorldBuilder` (`bollard`, `bench`, `trash_bin`, `planter_box`, + `newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`, + `traffic_cone`), then place the new dressing (`palm_tree`, `round_planter`, + `a_frame_sign`, `wall_lantern`, `crowd_barrier`, `stanchion`, `handrail`, + `security_podium`, `double_door`, `marquee`, `sedan`) using the existing + placement table/collider/avoidance contract. No prop may enter the + door/steps/guest lane. +7. **Wire emissives to the phase.** Street-lamp bulbs, wall lanterns, the door + lock LED, marquee bulbs/sign face, and sedan headlights should follow the + existing day/night lighting rather than staying always on. +8. **Measure and gate.** Re-run `smoke_npc_budget.gd` with the new characters + before raising `MAX_LIVE_NPCS`; add a prop triangle/draw budget check. Then + recapture `docs/screenshots/` and run `screen_designs.py compare` + `judge` + plus a close-up target shot for character readability. +9. **Fallbacks stay offline.** A fresh checkout with no Blender and no network + must boot on the committed library (or `npc_base.glb`/procedural rig when an + asset is absent); CI still runs the Godot import and headless suite. + + ### Phase 4 — Environment and lighting lift (after NPCs land) -1. **Baked AO on props:** export the existing prop set through Blender (or bake - AO in `npc_pipeline`-style tooling) so contact shadows read without SSAO. - This replaces the pure box/cylinder builder with Blender-authored props - behind the same `assets/models/generated/.glb` contract (ADR-0012's - follow-up). +1. **Baked AO on props:** use the Phase 3.5 Blender prop replacements + (`street_lamp`, `street_tree`, `bench`, …) and bake AO into their + albedo/ORM maps so contact shadows read without SSAO. Keep the same + `WorldBuilder` load/fallback contract (ADR-0012's follow-up). 2. **Higher-resolution environment textures:** regenerate brick/concrete/asphalt - through `asset_pipeline.py` with normal maps, keeping the current UV scales. + through `asset_pipeline.py` with normal maps, keeping the current UV scales; + use the same sRGB/albedo + packed ORM discipline as the library assets. 3. **Evaluate a desktop-only Forward+ preset** (SSAO/SSIL/volumetrics) while the Web export preset keeps `thread_support=false` and GL Compatibility. Decide explicitly in an ADR addendum; do not silently change the renderer. 4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run - `screen_designs.py compare` and `judge` as the regression gate. + `screen_designs.py compare` and `judge` as the regression gate, plus + `blender_assets.py sheet` for the character/gear/prop library. ### Phase 5 — Verification and release @@ -204,15 +273,20 @@ Outputs land under `assets/textures/ai/game_ready/` (or a new ## Acceptance criteria (definition of done) -1. NPCs are Blender-authored skinned meshes with baked maps; they read as people - at conversation distance and survive a cinematic close-up. +1. NPCs and street furniture are Blender-authored meshes with baked maps; the + character variants, gear, and props read as people/objects at conversation + distance and survive a cinematic close-up. 2. `Rig/Head`, the focus cone, nametag, theater poses, crowd reactions, and LOD all still work; all existing NPC tests pass unchanged or are updated with clear reasons. 3. The game boots and plays from a fresh checkout with **no** Blender and no network (committed assets + procedural fallback). 4. `npc_pipeline.py build` is deterministic and `audit` enforces the contract. -5. 208 headless + 56 Python tests stay green (Python audit tests skip when - Blender is absent); NPC-budget and movement smokes pass; screenshot - `compare`/`judge` show no regressions. -6. Version bumped to v0.6.0, docs and ADR-0013 updated, release published. +5. 212 headless + 60 Python tests stay green (Blender-dependent Python tests + skip with a reason when Blender is absent); `npc_pipeline.py audit` and + `blender_assets.py audit` pass; NPC-budget and movement smokes pass; + screenshot `compare`/`judge` and the library contact sheets show no + regressions. +6. The library is committed with its generator/manifest/previews, runtime + selection and fallbacks are covered by tests, version bumped to v0.6.0, + docs and ADR-0013 updated, release published. diff --git a/tools/blender_assets.py b/tools/blender_assets.py new file mode 100644 index 0000000..feff420 --- /dev/null +++ b/tools/blender_assets.py @@ -0,0 +1,1600 @@ +#!/usr/bin/env python3 +"""Blender-authored NPC characters, NPC gear, and street props for Gig Line. + +Everything here is modelled procedurally inside Blender (flatpak, headless) so +the assets are reproducible from source. Outputs are staged for *later* +integration: nothing in the game loads them yet (see +`docs/blender-assets.md` for the integration contract). + + # 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 + 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 list + +The flatpak sandbox cwd is /app/blender and it cannot read /tmp, so every path +is absolute and derived from this file's location. + +Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up): + * the front of every asset faces Blender -Y, which becomes Godot +Z (the + NPC face direction used by `NpcAvatar.yaw_towards`); + * grounded assets have their origin on the floor (min y ~= 0); + * 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), + material slots skin/cloth/pants/accessory/hair, same joint positions; + * gear is authored in attach-point space (see GEAR_ATTACH). +""" + +import argparse +import json +import math +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUT_DIR = ROOT / "assets" / "models" / "blender" +SRC_DIR = ROOT / "assets" / "models" / "blender_src" +PREVIEW_DIR = ROOT / "docs" / "blender_previews" +NPC_TEX_DIR = ROOT / "assets" / "textures" / "npc" +MANIFEST = OUT_DIR / "manifest.json" + +SLOTS = ["skin", "cloth", "pants", "accessory", "hair"] +BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", + "ElbowL", "ElbowR", "HandL", "HandR"] +CHAR_MAX_TRIS = 6000 +CHAR_MAX_VERTS = 4000 +GEAR_MAX_TRIS = 1500 +PROP_MAX_TRIS = 4000 + +try: + import bpy # type: ignore + import bmesh # type: ignore + from mathutils import Matrix, Vector # type: ignore +except ImportError: # pragma: no cover - audit/sheet/list run outside Blender + bpy = None + + +# --------------------------------------------------------------------------- +# Catalogue (pure data, readable without Blender) +# --------------------------------------------------------------------------- + +# Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can +# 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", + "roles": ["tourist", "confused_visitor", "intoxicated", "guest"]}, + "npc_jacket": {"layer": "jacket", "hair": "short", + "roles": ["authorized_staff", "protester", "guest"]}, + "npc_coat": {"layer": "coat", "hair": "long", + "roles": ["influencer", "trespasser", "violent_threat"]}, + "npc_vest": {"layer": "vest", "hair": "buzz", + "roles": ["delivery"]}, + "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", + "roles": ["performer"]}, + "npc_hoodie": {"layer": "hood", "hair": "short", + "roles": ["trespasser", "mental_crisis", "violent_threat"]}, + "npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad", + "roles": ["responder"]}, + "npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", + "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)", + "back": "Rig root, origin at the spine centre (0, 1.10, 0)", + "chest": "Rig root, origin at the sternum (0, 1.30, 0)", +} +GEAR = { + "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", + "firefighter_helmet": "head", + "backpack": "back", "shoulder_bag": "back", "scba_tank": "back", + "lanyard": "chest", + "phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand", + "camera": "hand", "protest_sign": "hand", + "flashlight": "hand", "wand": "hand", "radio": "hand", +} + +# Props: the first six replace assets/models/generated/.glb with the same +# footprint; the rest are new street/venue pieces. +PROPS = { + "bollard": {"replaces": "generated/bollard.glb"}, + "bench": {"replaces": "generated/bench.glb"}, + "trash_bin": {"replaces": "generated/trash_bin.glb"}, + "planter_box": {"replaces": "generated/planter_box.glb"}, + "newspaper_box": {"replaces": "generated/newspaper_box.glb"}, + "fire_hydrant": {"replaces": "generated/fire_hydrant.glb"}, + "street_lamp": {"replaces": "WorldBuilder lamp boxes", + "emissive": ["bulb"]}, + "street_tree": {"replaces": "WorldBuilder cone trees"}, + "palm_tree": {}, + "round_planter": {}, + "a_frame_sign": {}, + "wall_lantern": {"emissive": ["bulb"]}, + "traffic_cone": {"replaces": "prop accessory 'cone'"}, + "crowd_barrier": {}, + "stanchion": {}, + "handrail": {}, + "security_podium": {}, + "double_door": {"emissive": ["lock_led"]}, + "marquee": {"emissive": ["bulb", "sign_face"]}, + "sedan": {"emissive": ["headlight"]}, +} + + +def asset_path(category: str, name: str) -> Path: + return OUT_DIR / category / f"{name}.glb" + + +def catalogue(): + rows = [("npc", n) for n in CHARACTERS] + rows += [("npc_gear", n) for n in GEAR] + rows += [("props", n) for n in PROPS] + return rows + + +# --------------------------------------------------------------------------- +# Blender helpers +# --------------------------------------------------------------------------- + +def _srgb_to_linear(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +# name: (sRGB 0-255, roughness, metallic, emission strength) +PALETTE = { + "black_metal": ((34, 35, 38), 0.45, 0.8, 0.0), + "dark_metal": ((52, 55, 60), 0.4, 0.85, 0.0), + "steel": ((170, 172, 176), 0.3, 1.0, 0.0), + "chrome": ((215, 217, 220), 0.15, 1.0, 0.0), + "brass": ((181, 146, 72), 0.3, 1.0, 0.0), + "reflective": ((226, 222, 196), 0.25, 0.3, 0.0), + "bin_green": ((44, 70, 54), 0.55, 0.3, 0.0), + "news_blue": ((40, 88, 150), 0.5, 0.3, 0.0), + "hydrant_red": ((176, 46, 36), 0.5, 0.2, 0.0), + "wood": ((150, 92, 54), 0.7, 0.0, 0.0), + "dark_wood": ((84, 54, 34), 0.7, 0.0, 0.0), + "concrete": ((176, 172, 162), 0.9, 0.0, 0.0), + "terracotta": ((170, 100, 70), 0.85, 0.0, 0.0), + "stone_pot": ((196, 186, 168), 0.85, 0.0, 0.0), + "soil": ((58, 42, 30), 1.0, 0.0, 0.0), + "foliage": ((54, 104, 46), 0.85, 0.0, 0.0), + "foliage_dark": ((36, 78, 36), 0.85, 0.0, 0.0), + "palm_leaf": ((70, 112, 50), 0.8, 0.0, 0.0), + "bark": ((92, 70, 52), 0.95, 0.0, 0.0), + "palm_bark": ((128, 104, 78), 0.95, 0.0, 0.0), + "glass": ((90, 120, 140), 0.08, 0.2, 0.0), + "dark_glass": ((22, 26, 32), 0.06, 0.3, 0.0), + "paper": ((228, 224, 210), 0.9, 0.0, 0.0), + "chalkboard": ((32, 38, 36), 0.9, 0.0, 0.0), + "chalk": ((232, 232, 226), 0.9, 0.0, 0.0), + "plastic_black": ((26, 27, 30), 0.5, 0.0, 0.0), + "plastic_grey": ((110, 114, 120), 0.5, 0.0, 0.0), + "plastic_white": ((222, 222, 218), 0.45, 0.0, 0.0), + "cone_orange": ((236, 96, 24), 0.55, 0.0, 0.0), + "rubber": ((22, 22, 24), 0.9, 0.0, 0.0), + "velvet_red": ((128, 20, 30), 0.9, 0.0, 0.0), + "awning_red": ((150, 34, 38), 0.8, 0.0, 0.0), + "door_paint": ((38, 40, 44), 0.5, 0.4, 0.0), + "car_paint": ((18, 20, 24), 0.2, 0.6, 0.0), + "bulb": ((255, 226, 160), 0.3, 0.0, 6.0), + "headlight": ((240, 240, 255), 0.2, 0.0, 3.0), + "lock_led": ((255, 40, 30), 0.3, 0.0, 4.0), + "sign_face": ((255, 236, 196), 0.4, 0.0, 2.5), + "hivis": ((214, 228, 40), 0.7, 0.0, 0.0), + "leather": ((54, 38, 28), 0.6, 0.0, 0.0), + "denim_bag": ((60, 72, 96), 0.8, 0.0, 0.0), + "pack_nylon": ((40, 64, 92), 0.7, 0.0, 0.0), + "lens": ((20, 22, 26), 0.05, 0.5, 0.0), + "screen": ((26, 40, 70), 0.1, 0.0, 0.6), + "cardboard": ((186, 150, 104), 0.9, 0.0, 0.0), + "ribbon_blue": ((40, 80, 170), 0.7, 0.0, 0.0), + "turnout": ((36, 40, 46), 0.85, 0.0, 0.0), + "helmet": ((24, 26, 30), 0.3, 0.1, 0.0), + "undershirt": ((60, 62, 68), 0.9, 0.0, 0.0), + "uniform": ((30, 34, 46), 0.85, 0.0, 0.0), + "patch": ((190, 150, 60), 0.6, 0.2, 0.0), +} + +_MATS = {} + + +def _solid_image(name, rgb): + """4x4 sRGB texture so a runtime albedo_color=WHITE tint keeps the colour.""" + img = bpy.data.images.new(f"{name}_albedo", 4, 4, alpha=False) + px = [rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0] * 16 + img.pixels = px + img.pack() + return img + + +def material(name, textured_solid=False): + """Principled material from PALETTE (or an npc slot texture set).""" + key = (name, textured_solid) + if key in _MATS: + return _MATS[key] + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nodes = mat.node_tree.nodes + links = mat.node_tree.links + bsdf = nodes.get("Principled BSDF") + if name in SLOTS: + # Same neutral maps as npc_base, so Materials.npc_slot() tints alike. + albedo = nodes.new("ShaderNodeTexImage") + albedo.image = bpy.data.images.load(str(NPC_TEX_DIR / f"{name}_albedo.png"), + check_existing=True) + albedo.image.colorspace_settings.name = "sRGB" + links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"]) + normal_tex = nodes.new("ShaderNodeTexImage") + normal_tex.image = bpy.data.images.load( + str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True) + normal_tex.image.colorspace_settings.name = "Non-Color" + nmap = nodes.new("ShaderNodeNormalMap") + links.new(normal_tex.outputs["Color"], nmap.inputs["Color"]) + links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) + rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92, + "accessory": 0.5, "hair": 0.7}[name] + bsdf.inputs["Roughness"].default_value = rough + bsdf.inputs["Metallic"].default_value = 0.0 + else: + rgb, rough, metal, emit = PALETTE[name] + lin = tuple(_srgb_to_linear(c / 255.0) for c in rgb) + (1.0,) + bsdf.inputs["Base Color"].default_value = lin + bsdf.inputs["Roughness"].default_value = rough + bsdf.inputs["Metallic"].default_value = metal + if textured_solid: + tex = nodes.new("ShaderNodeTexImage") + tex.image = _solid_image(name, rgb) + tex.interpolation = "Closest" + links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + if emit > 0.0: + bsdf.inputs["Emission Color"].default_value = lin + bsdf.inputs["Emission Strength"].default_value = emit + _MATS[key] = mat + return mat + + +def reset_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + _MATS.clear() + + +def _link(name, bm): + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def _xf(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)): + from mathutils import Euler + m = Matrix.Translation(Vector(loc)) @ Euler(rot, "XYZ").to_matrix().to_4x4() + s = Matrix.Diagonal(Vector((scale[0], scale[1], scale[2], 1.0))) + return m @ s + + +def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1): + obj.data.materials.append(mat) + if bevel > 0.0: + mod = obj.modifiers.new("Bevel", "BEVEL") + mod.width = bevel + mod.segments = bevel_segments + mod.limit_method = "ANGLE" + mod.angle_limit = math.radians(40) + _apply_all(obj) + if smooth: + obj.data.shade_smooth() + obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle)) + else: + obj.data.shade_flat() + return obj + + +def _apply_all(obj): + bpy.context.view_layer.objects.active = obj + for o in bpy.context.selected_objects: + o.select_set(False) + obj.select_set(True) + for mod in list(obj.modifiers): + bpy.ops.object.modifier_apply(modifier=mod.name) + + +def box(name, size, loc, mat, rot=(0, 0, 0), bevel=0.0, segs=1): + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0, matrix=_xf(loc, rot, size)) + return _finish(_link(name, bm), mat, smooth=bevel > 0, bevel=bevel, + bevel_segments=segs) + + +def cyl(name, r1, r2, depth, loc, mat, seg=16, rot=(0, 0, 0), smooth=True, + scale=(1, 1, 1), caps=True, bevel=0.0): + bm = bmesh.new() + bmesh.ops.create_cone(bm, cap_ends=caps, cap_tris=False, segments=seg, + radius1=r1, radius2=r2, depth=depth, + matrix=_xf(loc, rot, scale)) + return _finish(_link(name, bm), mat, smooth=smooth, bevel=bevel) + + +def sphere(name, r, loc, mat, seg=16, rings=10, scale=(1, 1, 1), rot=(0, 0, 0), + cut_below=None, smooth=True): + """UV sphere; `cut_below` drops vertices under a local z (0..1 of radius).""" + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=r) + if cut_below is not None: + doomed = [v for v in bm.verts if v.co.z < cut_below * r - 1e-6] + bmesh.ops.delete(bm, geom=doomed, context="VERTS") + bmesh.ops.transform(bm, matrix=_xf(loc, rot, scale), verts=bm.verts) + return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=60) + + +def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6, + scale=(1, 1, 1)): + bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor, + major_segments=seg, minor_segments=minor_seg) + obj = bpy.context.object + obj.name = name + obj.data.transform(_xf(loc, rot, scale)) + 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.""" + bm = bmesh.new() + rings = [] + for r, z in profile: + ring = [] + for i in range(seg): + a = math.tau * i / seg + ring.append(bm.verts.new((loc[0] + r * math.cos(a), + loc[1] + r * math.sin(a), loc[2] + z))) + rings.append(ring) + for a, b in zip(rings, rings[1:]): + 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: + bm.faces.new(list(reversed(rings[0]))) + if 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) + return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=sharp) + + +def tube(name, points, radius, mat, seg=8, caps=True): + """Bent tube through 3D points (handrails, umbrella handles, frames).""" + bm = bmesh.new() + rings = [] + pts = [Vector(p) for p in points] + for i, p in enumerate(pts): + if i == 0: + d = (pts[1] - p).normalized() + elif i == len(pts) - 1: + d = (p - pts[i - 1]).normalized() + else: + d = ((pts[i + 1] - p).normalized() + (p - pts[i - 1]).normalized()).normalized() + up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0)) + side = d.cross(up).normalized() + up2 = side.cross(d).normalized() + ring = [] + for k in range(seg): + a = math.tau * k / seg + ring.append(bm.verts.new(p + radius * (math.cos(a) * side + math.sin(a) * up2))) + rings.append(ring) + for a, b in zip(rings, rings[1:]): + for k in range(seg): + j = (k + 1) % seg + bm.faces.new((a[k], a[j], b[j], b[k])) + if caps: + bm.faces.new(list(reversed(rings[0]))) + bm.faces.new(rings[-1]) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60) + + +def join(objs, name): + for o in bpy.context.selected_objects: + o.select_set(False) + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + if len(objs) > 1: + bpy.ops.object.join() + obj = bpy.context.object + obj.name = name + obj.data.name = name + return obj + + +def uv_unwrap(obj): + 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.object.mode_set(mode="OBJECT") + + +def export(objs, category, name): + path = asset_path(category, name) + path.parent.mkdir(parents=True, exist_ok=True) + for o in bpy.context.selected_objects: + o.select_set(False) + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + bpy.ops.export_scene.gltf( + filepath=str(path), export_format="GLB", use_selection=True, + export_apply=True, export_animations=False, export_skins=True, + export_yup=True, export_materials="EXPORT", export_image_format="AUTO") + SRC_DIR.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"), + compress=True) + return path + + +# --------------------------------------------------------------------------- +# Characters +# --------------------------------------------------------------------------- + +# Bone layout copied from tools/npc_pipeline.py so NpcAvatar's proxies line up. +def _bone_layout(): + b = {"Hips": ((0, 0, 0.62), (0, 0, 0.82), None), + "Spine": ((0, 0, 0.82), (0, 0, 1.40), "Hips"), + "Neck": ((0, 0, 1.40), (0, 0, 1.52), "Spine"), + "Head": ((0, 0, 1.52), (0, 0, 1.82), "Neck")} + for s, suf in ((-1.0, "L"), (1.0, "R")): + x = 0.27 * s + hx = 0.12 * s + b[f"Shoulder{suf}"] = ((x, 0, 1.38), (x, 0, 1.06), "Spine") + b[f"Elbow{suf}"] = ((x, 0, 1.06), (x, 0, 0.78), f"Shoulder{suf}") + b[f"Hand{suf}"] = ((x, 0, 0.78), (x, 0, 0.68), f"Elbow{suf}") + b[f"Hip{suf}"] = ((hx, 0, 0.62), (hx, 0, 0.30), "Hips") + b[f"Knee{suf}"] = ((hx, 0, 0.30), (hx, 0, 0.0), f"Hip{suf}") + return b + + +BUILDS = { # torso width / depth multipliers, limb thickness + "average": (1.0, 1.0, 1.0), + "slim": (0.9, 0.92, 0.88), + "broad": (1.12, 1.08, 1.1), +} + + +def _skin_body(spec): + """Connected torso + limbs from a Skin modifier graph, subdivided once.""" + w, d, t = BUILDS[spec.get("build", "average")] + layer = spec["layer"] + bulk = 1.0 + {"coat": 0.06, "jacket": 0.05, "hood": 0.06, "responder": 0.16, + "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) + 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, 1.44), 0.058, 0.058), # 4 neck base + ((0, -0.005, 1.53), 0.052, 0.052), # 5 neck top + ] + edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)] + 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.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 + ] + edges += [(3, base), (base, base + 1), (base + 1, base + 2), (base + 2, base + 3)] + hb = len(nodes) + nodes += [ + ((0.095 * s, 0, 0.72), 0.095 * t * leg, 0.10 * t * leg), # hip joint + ((0.115 * s, 0, 0.52), 0.080 * t * leg, 0.085 * t * leg), # thigh + ((0.12 * s, 0.0, 0.31), 0.058 * t * leg, 0.062 * t * leg), # knee + ((0.12 * s, 0.01, 0.18), 0.055 * t * leg, 0.058 * t * leg), # calf + ((0.12 * s, 0, 0.08), 0.040 * leg, 0.042 * leg), # ankle + ] + edges += [(0, hb), (hb, hb + 1), (hb + 1, hb + 2), (hb + 2, hb + 3), (hb + 3, hb + 4)] + me = bpy.data.meshes.new("body") + me.from_pydata([n[0] for n in nodes], edges, []) + obj = bpy.data.objects.new("body", me) + bpy.context.scene.collection.objects.link(obj) + skin = obj.modifiers.new("Skin", "SKIN") + skin.branch_smoothing = 0.6 + for i, (_, rx, ry) in enumerate(nodes): + me.skin_vertices[0].data[i].radius = (rx, ry) + me.skin_vertices[0].data[0].use_root = True + sub = obj.modifiers.new("Sub", "SUBSURF") + sub.levels = 2 + dec = obj.modifiers.new("Decimate", "DECIMATE") + dec.ratio = 0.5 + dec.use_symmetry = True + dec.symmetry_axis = "X" + _apply_all(obj) + # Clean edge loops where materials change (waist, cuffs, short sleeves, neck). + bm = bmesh.new() + bm.from_mesh(obj.data) + 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)) + bm.to_mesh(obj.data) + bm.free() + # Front of the chest/backside slightly shaped: push chest forward a touch. + for v in obj.data.vertices: + if 1.05 < v.co.z < 1.38 and abs(v.co.x) < 0.17 and v.co.y < 0: + v.co.y -= 0.012 * (1.0 - abs(v.co.x) / 0.17) + if 0.72 < v.co.z < 0.9 and abs(v.co.x) < 0.17 and v.co.y > 0: + v.co.y += 0.015 + obj.data.shade_smooth() + return obj + + +def _assign_body_slots(obj, spec, mats): + """Pick a slot per face from its centroid: skin / cloth / pants.""" + layer = spec["layer"] + names = [m.name for m in mats] + obj.data.materials.clear() + for m in 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 + for poly in obj.data.polygons: + c = poly.center + arm = abs(c.x) > 0.205 and c.z > 0.78 + if c.z > 1.43 and not arm: + slot = "skin" + elif arm: + if c.z < 0.86 or (short_sleeve and c.z < 1.19): + slot = "skin" + else: + slot = top_slot + elif c.z < waist: + slot = leg_slot + if layer == "dress" and c.z > 0.40: + slot = "cloth" + else: + slot = top_slot + poly.material_index = idx[slot] + + +def _seg_dist(p, a, b): + ab = b - a + t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-9))) + return (p - (a + ab * t)).length + + +def _auto_weights(obj, layout, rigid=None): + """Distance-to-bone weights limited by body region (no heat-solve flakes).""" + segs = {n: (Vector(h), Vector(t)) for n, (h, t, _) in layout.items()} + groups = {n: obj.vertex_groups.new(name=n) for n in layout} + for v in obj.data.vertices: + p = v.co + side = "L" if p.x < 0 else "R" + if rigid: + bone = rigid(p) + if bone: + groups[bone].add([v.index], 1.0, "REPLACE") + continue + if abs(p.x) > 0.19 and p.z > 0.74 and p.z < 1.42: + allowed = ["Spine", f"Shoulder{side}", f"Elbow{side}", f"Hand{side}"] + if abs(p.x) > 0.23: + allowed.remove("Spine") + elif p.z < 0.80: + allowed = ["Hips", f"Hip{side}", f"Knee{side}"] + if p.z < 0.66: + allowed.remove("Hips") + elif p.z > 1.40: + allowed = ["Spine", "Neck", "Head"] + else: + allowed = ["Hips", "Spine", "Neck"] + scored = sorted((_seg_dist(p, *segs[b]), b) for b in allowed)[:2] + ws = [(1.0 / (dd + 0.02) ** 4, b) for dd, b in scored] + total = sum(w for w, _ in ws) + for w, b in ws: + if w / total > 0.02: + groups[b].add([v.index], w / total, "REPLACE") + + +def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645), + scale=(0.95, 1.04, 1.1)): + """Head-hugging cap: cut at the brow in front and lower at the back.""" + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=radius) + bmesh.ops.transform(bm, matrix=_xf(center, (0, 0, 0), scale), verts=bm.verts) + doomed = [] + for v in bm.verts: + t = max(0.0, min(1.0, 0.5 - (v.co.y - center[1]) / (radius * 1.2))) + cut = back_z + (front_z - back_z) * t + if v.co.z < cut: + doomed.append(v) + bmesh.ops.delete(bm, geom=doomed, context="VERTS") + return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70) + + +def _hair(style, mat_hair, broad=False): + parts = [] + if style == "buzz": + parts.append(_hair_cap("Hair", mat_hair, 1.705, 1.58, radius=0.158)) + elif style in ("short", "long", "bun"): + parts.append(_hair_cap("Hair", mat_hair, 1.715, 1.56)) + parts.append(sphere("Fringe", 0.08, (0.025, -0.115, 1.755), mat_hair, seg=10, + rings=6, scale=(1.35, 0.5, 0.42), rot=(0.35, 0, -0.18))) + if style == "long": + parts.append(sphere("HairBack", 0.15, (0, 0.06, 1.52), mat_hair, seg=14, rings=8, + scale=(1.0, 0.5, 1.35))) + for s in (-1.0, 1.0): + parts.append(sphere("Lock", 0.06, (0.125 * s, -0.01, 1.57), mat_hair, seg=8, + rings=6, scale=(0.6, 0.9, 1.9))) + if style == "bun": + parts.append(sphere("Bun", 0.065, (0, 0.13, 1.76), mat_hair, seg=12, rings=8)) + return parts + + +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, + scale=(0.92, 1.0, 1.12))] + # jaw / chin softens the egg into a face + parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.56), skin, seg=12, rings=8, + scale=(1.0, 1.0, 0.8))) + parts.append(cyl("Nose", 0.022, 0.006, 0.05, (0, -0.158, 1.632), skin, seg=6, + rot=(math.radians(-80), 0, 0), smooth=True)) + for s in (-1.0, 1.0): + parts.append(sphere("Ear", 0.035, (0.142 * s, 0.01, 1.63), skin, seg=8, rings=5, + scale=(0.45, 1.0, 1.35))) + parts.append(sphere("Eye", 0.017, (0.05 * s, -0.142, 1.665), acc, seg=8, rings=5, + scale=(1.2, 0.6, 0.9))) + parts.append(box("Brow", (0.05, 0.012, 0.011), (0.05 * s, -0.146, 1.70), hair, + 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, + scale=(1.0, 0.9, 0.75), cut_below=-0.2)) + return parts + + +def _hands_and_shoes(mats, spec): + skin = mats["skin"] + shoe = mats["accessory"] + parts = [] + 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, + 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)) + parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01), + material("rubber", textured_solid=True))) + return parts + + +def _clothing(spec, mats): + layer = spec["layer"] + cloth = mats["cloth"] + acc = mats["accessory"] + 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)) + for o in parts[-1:]: + for v in o.data.vertices: # flatten front/back + v.co.y *= 0.78 + if layer in ("coat", "jacket", "guard", "responder"): + mat = cloth if layer != "responder" else mats["turnout"] + 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)) + if layer == "vest": + hv = mats["hivis"] + refl = mats["reflective"] + parts.append(lathe("Vest", [(0.162, 0.86), (0.18, 0.98), (0.19, 1.18), + (0.19, 1.30), (0.15, 1.40)], hv, seg=20, smooth=True)) + for v in parts[-1].data.vertices: + v.co.y *= 0.70 + for z in (0.98, 1.08): + band = torus("Band", 0.188, 0.012, (0, 0, z), refl, seg=24, minor_seg=4) + for v in band.data.vertices: + v.co.y *= 0.70 + parts.append(band) + for s in (-1.0, 1.0): + 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)) + 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 v in band.data.vertices: + v.co.y *= 0.78 + 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, + rot=(0, 0, 0), seg=14, minor_seg=4)) + parts.append(box("Boot", (0.12, 0.27, 0.22), (0.12 * s, -0.04, 0.11), + mats["rubber"], bevel=0.03, segs=2)) + if layer == "guard": + belt = torus("Belt", 0.168, 0.022, (0, 0, 0.86), mats["leather"], seg=24, + minor_seg=4, scale=(1.0, 0.72, 1.4)) + parts.append(belt) + parts.append(box("Buckle", (0.05, 0.012, 0.035), (0, -0.125, 0.86), mats["steel"])) + parts.append(box("Badge", (0.045, 0.01, 0.055), (0.085, -0.128, 1.27), mats["patch"], + bevel=0.006)) + for s in (-1.0, 1.0): + 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)) + parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30), + mats["plastic_black"], bevel=0.008)) + return parts + + +def build_character(name): + spec = dict(CHARACTERS[name]) + if name == "npc_hoodie": + spec["beard"] = True + mats = {slot: material(slot) for slot in SLOTS} + for extra in ("undershirt", "turnout", "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"]] + _assign_body_slots(body, spec, body_mats) + _auto_weights(body, layout) + + head_parts = _face(mats, spec) + hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz" + head_parts += _hair(hair_style, mats["hair"]) + head = join(head_parts, "head") + _auto_weights(head, layout, rigid=lambda p: "Head") + + hs = _hands_and_shoes(mats, spec) + if spec["layer"] == "responder": # boots replace the shoes + hs = [o for o in hs if not o.name.startswith(("Shoe", "Sole"))] or hs + limbs = join(hs, "limbs") + + def limb_bone(p): + side = "L" if p.x < 0 else "R" + return f"Hand{side}" if p.z > 0.5 else f"Knee{side}" + _auto_weights(limbs, layout, rigid=limb_bone) + + rest = [body, head, limbs] + clothes = _clothing(spec, mats) + if clothes: + cl = join(clothes, "clothing") + + def cloth_bone(p): + if p.z > 1.42: + return "Head" if p.z > 1.55 else "Neck" + side = "L" if p.x < 0 else "R" + if abs(p.x) > 0.26 and p.z > 0.8: + return f"Elbow{side}" + if p.z < 0.25: + return f"Knee{side}" + return None + _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) + + arm_data = bpy.data.armatures.new("Armature") + arm = bpy.data.objects.new("Armature", arm_data) + bpy.context.scene.collection.objects.link(arm) + bpy.context.view_layer.objects.active = arm + for o in bpy.context.selected_objects: + o.select_set(False) + arm.select_set(True) + bpy.ops.object.mode_set(mode="EDIT") + eb = arm_data.edit_bones + for bone_name, (h, t, parent) in layout.items(): + bone = eb.new(bone_name) + bone.head, bone.tail = h, t + if parent: + bone.parent = eb[parent] + bpy.ops.object.mode_set(mode="OBJECT") + mesh.parent = arm + mod = mesh.modifiers.new("Armature", "ARMATURE") + mod.object = arm + return [arm, mesh] + + +# --------------------------------------------------------------------------- +# Gear (static, attach-point space; see GEAR_ATTACH) +# --------------------------------------------------------------------------- + +def build_gear(name): + M = lambda n: material(n) # noqa: E731 + p = [] + # Head gear: origin = head centre (Godot Rig/Head marker at y=1.62); the + # Blender model's head centre sits at z=1.635, so offsets are ~ +0.015. + if name == "cap": + p.append(sphere("Crown", 0.168, (0, 0.01, 0.05), M("plastic_black"), seg=20, + rings=12, scale=(1.0, 1.05, 0.85), cut_below=0.0)) + p.append(cyl("Brim", 0.11, 0.11, 0.012, (0, -0.17, 0.055), M("plastic_black"), + seg=16, scale=(1.0, 0.8, 1.0), rot=(math.radians(-8), 0, 0))) + p.append(sphere("Button", 0.014, (0, 0.01, 0.195), M("plastic_black"), seg=8, rings=4)) + elif name == "beanie": + p.append(sphere("Knit", 0.172, (0, 0.01, 0.04), M("plastic_grey"), seg=20, rings=12, + 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() + 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), + 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": + p.append(sphere("Shell", 0.19, (0, 0.0, 0.03), M("helmet"), seg=24, rings=12, + scale=(1.0, 1.08, 0.95), cut_below=0.0)) + p.append(lathe("Brim", [(0.0, 0.025), (0.20, 0.025), (0.26, 0.015), + (0.255, 0.035), (0.0, 0.04)], M("helmet"), seg=24)) + for v in p[-1].data.vertices: + if v.co.y > 0: + v.co.y *= 1.35 # long rear brim + p.append(torus("Band", 0.188, 0.014, (0, 0, 0.09), M("hivis"), seg=24, minor_seg=4, + scale=(1.0, 1.08, 1.0))) + p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01)) + p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01)) + # Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind. + elif name == "backpack": + p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"), + bevel=0.04, segs=2)) + p.append(box("Pocket", (0.22, 0.05, 0.16), (0, 0.29, -0.02), M("pack_nylon"), + bevel=0.02, segs=2)) + p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel"))) + for s in (-1.0, 1.0): + p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.10 * s, -0.02, 0.29), + (0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)], + 0.012, M("plastic_black"), seg=6)) + elif name == "shoulder_bag": + p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"), + bevel=0.025, segs=2)) + p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26), + (-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05), + (0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6)) + elif name == "scba_tank": + p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18)) + p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8, + cut_below=0.0)) + p.append(cyl("Valve", 0.03, 0.03, 0.06, (0, 0.21, -0.29), M("brass"), seg=10)) + p.append(box("Frame", (0.30, 0.04, 0.60), (0, 0.12, 0.0), M("plastic_black"), + bevel=0.015)) + for s in (-1.0, 1.0): + p.append(tube("Strap", [(0.11 * s, 0.10, 0.28), (0.11 * s, -0.03, 0.31), + (0.12 * s, -0.14, 0.18), (0.13 * s, -0.13, -0.15)], + 0.016, M("plastic_black"), seg=6)) + # Chest: origin = sternum (Godot 0, 1.30, 0). + elif name == "lanyard": + p.append(tube("Cord", [(-0.06, 0.0, 0.16), (-0.07, -0.10, 0.10), (-0.04, -0.135, -0.05), + (0.0, -0.14, -0.14), (0.04, -0.135, -0.05), (0.07, -0.10, 0.10), + (0.06, 0.0, 0.16)], 0.005, M("ribbon_blue"), seg=5, caps=False)) + p.append(box("Badge", (0.06, 0.006, 0.085), (0, -0.145, -0.19), M("plastic_white"), + bevel=0.004)) + p.append(box("Photo", (0.024, 0.002, 0.03), (-0.012, -0.149, -0.175), M("ribbon_blue"))) + # Hand items: origin = grip centre; items hang along -Z (Godot -Y). + elif name == "phone": + p.append(box("Body", (0.075, 0.012, 0.15), (0, -0.02, 0.02), M("plastic_black"), + bevel=0.008, segs=2)) + p.append(box("Screen", (0.066, 0.002, 0.135), (0, -0.0265, 0.02), M("screen"))) + elif name == "cup": + p.append(lathe("Cup", [(0.0, -0.06), (0.032, -0.06), (0.042, 0.06), (0.0, 0.06)], + M("paper"), seg=16)) + p.append(lathe("Lid", [(0.0, 0.06), (0.046, 0.06), (0.044, 0.078), (0.0, 0.082)], + M("plastic_white"), seg=16)) + p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16, + smooth=True)) + elif name == "clipboard": + p.append(box("Board", (0.23, 0.008, 0.31), (0, -0.04, -0.06), M("dark_wood"), + bevel=0.004)) + p.append(box("Paper", (0.20, 0.002, 0.26), (0, -0.045, -0.08), M("paper"))) + p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, 0.085), M("steel"), bevel=0.004)) + elif name == "umbrella": + p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14), + (0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8)) + p.append(cyl("Shaft", 0.009, 0.009, 0.70, (0, 0, 0.29), M("black_metal"), seg=8)) + canopy = lathe("Canopy", [(0.0, 0.66), (0.10, 0.65), (0.30, 0.58), (0.45, 0.48)], + M("plastic_black"), seg=8, sharp=20) + p.append(canopy) + elif name == "camera": + p.append(box("Body", (0.13, 0.07, 0.085), (0, -0.04, 0.04), M("plastic_black"), + bevel=0.01, segs=2)) + p.append(cyl("Lens", 0.032, 0.03, 0.07, (0, -0.105, 0.035), M("plastic_black"), + seg=16, rot=(math.radians(90), 0, 0))) + p.append(cyl("Glass", 0.024, 0.024, 0.004, (0, -0.141, 0.035), M("lens"), seg=16, + rot=(math.radians(90), 0, 0))) + p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"), + bevel=0.006)) + p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42), + (0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006, + M("plastic_black"), seg=5)) + elif name == "protest_sign": + p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood"))) + p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"), + bevel=0.003)) + for i, z in enumerate((0.90, 0.80, 0.72)): + p.append(box("Letters", (0.46 - i * 0.08, 0.002, 0.04), (0, -0.032, z), + M("plastic_black"))) + elif name == "flashlight": + p.append(cyl("Barrel", 0.017, 0.017, 0.20, (0, -0.02, 0.0), M("black_metal"), seg=14, + rot=(math.radians(90), 0, 0))) + p.append(cyl("Head", 0.027, 0.019, 0.05, (0, -0.135, 0.0), M("black_metal"), seg=14, + rot=(math.radians(-90), 0, 0))) + p.append(cyl("LensF", 0.023, 0.023, 0.004, (0, -0.161, 0.0), M("bulb"), seg=14, + rot=(math.radians(90), 0, 0))) + p.append(box("Switch", (0.01, 0.02, 0.008), (0, -0.06, 0.019), M("rubber"))) + elif name == "wand": + p.append(cyl("Grip", 0.019, 0.017, 0.15, (0, 0, 0.0), M("plastic_black"), seg=12)) + p.append(box("Paddle", (0.075, 0.025, 0.25), (0, 0, 0.20), M("plastic_grey"), + bevel=0.012, segs=2)) + p.append(box("Coil", (0.055, 0.027, 0.18), (0, 0, 0.21), M("plastic_black"), + bevel=0.008)) + p.append(sphere("Led", 0.007, (0, -0.016, 0.06), M("lock_led"), seg=8, rings=4)) + elif name == "radio": + p.append(box("Body", (0.058, 0.032, 0.115), (0, 0, 0.0), M("plastic_black"), + bevel=0.007, segs=2)) + p.append(cyl("Antenna", 0.007, 0.005, 0.09, (0.018, 0.0, 0.10), M("rubber"), seg=8)) + p.append(cyl("Knob", 0.008, 0.008, 0.015, (-0.015, 0.0, 0.065), M("plastic_grey"), + seg=10)) + p.append(box("Grille", (0.044, 0.002, 0.05), (0, -0.017, -0.02), M("dark_metal"))) + p.append(sphere("Led", 0.004, (0.0, -0.016, 0.045), M("lock_led"), seg=6, rings=4)) + else: + raise KeyError(name) + obj = join(p, name) + uv_unwrap(obj) + return [obj] + + +# --------------------------------------------------------------------------- +# Props (grounded, front = Blender -Y) +# --------------------------------------------------------------------------- + +def _shrub(name, center, radii, mat, lumps=7, seed=1): + """Lumpy foliage cluster from overlapping low-poly spheres.""" + parts = [] + import random + rng = random.Random(seed) + parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii)) + for i in range(lumps): + a = math.tau * i / lumps + rng.uniform(-0.3, 0.3) + r = rng.uniform(0.45, 0.6) + off = (center[0] + math.cos(a) * radii[0] * 0.6, + center[1] + math.sin(a) * radii[1] * 0.6, + center[2] + rng.uniform(-0.1, 0.35) * radii[2]) + parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6, + scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9))) + return parts + + +def build_prop(name): + M = lambda n: material(n) # noqa: E731 + p = [] + if name == "bollard": # matches generated: r 0.09, h 0.97 + p.append(lathe("Bollard", [(0.105, 0.0), (0.105, 0.04), (0.085, 0.07), (0.08, 0.80), + (0.09, 0.84), (0.09, 0.90), (0.06, 0.96), (0.0, 0.975)], + M("black_metal"), seg=8, sharp=25)) + p.append(lathe("Band", [(0.0815, 0.74), (0.0815, 0.79)], M("reflective"), seg=8, + smooth=False)) + elif name == "bench": # 1.80 x 0.52 seat at 0.46, backrest at back (+Y) + for i, y in enumerate((-0.17, -0.055, 0.06, 0.175)): + p.append(box("Seat", (1.80, 0.10, 0.04), (0, y, 0.46), M("wood"), bevel=0.012)) + for z in (0.62, 0.76): + p.append(box("Back", (1.80, 0.035, 0.11), (0, 0.235, z), M("wood"), + rot=(math.radians(-10), 0, 0), bevel=0.012)) + for x in (-0.72, 0.72): + p.append(tube("Frame", [(x, -0.22, 0.0), (x, -0.20, 0.44), (x, 0.20, 0.44), + (x, 0.22, 0.0)], 0.022, M("black_metal"), seg=8)) + p.append(tube("BackFrame", [(x, 0.20, 0.44), (x, 0.26, 0.84)], 0.02, + M("black_metal"), seg=8)) + p.append(tube("Arm", [(x, -0.20, 0.44), (x, -0.20, 0.62), (x, 0.08, 0.64), + (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)) + p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8, + smooth=False)) + 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), + 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"), + bevel=0.025, segs=2)) + p.append(box("Lip", (1.0, 1.0, 0.05), (0, 0, 0.515), M("stone_pot"), bevel=0.015)) + p.append(box("Soil", (0.86, 0.86, 0.02), (0, 0, 0.535), M("soil"))) + p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=8, seed=3) + elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92 + p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01)) + p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02)) + p.append(box("Top", (0.44, 0.38, 0.08), (0, 0.0, 0.84), M("news_blue"), + rot=(math.radians(-12), 0, 0), bevel=0.015)) + p.append(box("Window", (0.32, 0.01, 0.22), (0, -0.172, 0.62), M("glass"))) + p.append(box("Paper", (0.26, 0.004, 0.14), (0, -0.168, 0.60), M("paper"))) + p.append(box("Handle", (0.14, 0.03, 0.02), (0, -0.18, 0.47), M("steel"), bevel=0.006)) + p.append(box("Coin", (0.06, 0.02, 0.08), (0.14, -0.175, 0.76), M("steel"), bevel=0.005)) + elif name == "fire_hydrant": # r 0.15-0.19, h ~0.74 + p.append(lathe("Hydrant", [(0.19, 0.0), (0.19, 0.06), (0.15, 0.09), (0.14, 0.52), + (0.17, 0.55), (0.17, 0.60), (0.12, 0.66), (0.05, 0.72), + (0.05, 0.75), (0.0, 0.76)], M("hydrant_red"), seg=12, + sharp=30)) + for s in (-1.0, 1.0): + p.append(cyl("Nozzle", 0.05, 0.05, 0.10, (0.17 * s, 0, 0.40), M("hydrant_red"), + seg=10, rot=(0, math.radians(90), 0))) + p.append(cyl("Cap", 0.055, 0.055, 0.03, (0.225 * s, 0, 0.40), M("chrome"), seg=6, + rot=(0, math.radians(90), 0), smooth=False)) + p.append(cyl("FrontCap", 0.065, 0.065, 0.05, (0, -0.16, 0.40), M("chrome"), seg=6, + rot=(math.radians(90), 0, 0), smooth=False)) + for a in range(6): + ang = math.tau * a / 6 + p.append(cyl("Bolt", 0.012, 0.012, 0.02, (math.cos(ang) * 0.16, + math.sin(ang) * 0.16, 0.07), + M("chrome"), seg=6, smooth=False)) + elif name == "street_lamp": # 5 m, arm toward -X, bulb ~4.85 m + p.append(lathe("Base", [(0.20, 0.0), (0.20, 0.08), (0.15, 0.14), (0.13, 0.60), + (0.10, 0.66), (0.08, 0.70)], M("black_metal"), seg=8, sharp=25)) + p.append(lathe("Pole", [(0.08, 0.70), (0.055, 4.70), (0.07, 4.76), (0.07, 4.82), + (0.0, 4.84)], M("black_metal"), seg=8, sharp=25)) + p.append(tube("Arm", [(0.0, 0, 4.55), (-0.3, 0, 4.85), (-0.7, 0, 4.95), (-0.85, 0, 4.92)], + 0.035, M("black_metal"), seg=8)) + p.append(lathe("Hood", [(0.0, 4.98), (0.06, 4.97), (0.24, 4.85), (0.25, 4.82)], + M("black_metal"), seg=12, loc=(-0.85, 0, 0))) + p.append(lathe("BulbGlass", [(0.0, 4.82), (0.17, 4.82), (0.10, 4.74), (0.0, 4.72)], + M("bulb"), seg=12, loc=(-0.85, 0, 0))) + p.append(torus("Ring", 0.07, 0.015, (0, 0, 3.0), M("black_metal"), seg=12, minor_seg=4)) + elif name == "street_tree": # trunk + layered crown, ~3.6 m + p.append(lathe("Trunk", [(0.15, 0.0), (0.11, 0.25), (0.09, 1.4), (0.07, 2.0)], + M("bark"), seg=8)) + p.append(tube("Branch", [(0, 0, 1.6), (0.35, 0.1, 2.2)], 0.04, M("bark"), seg=6)) + p.append(tube("Branch", [(0, 0, 1.7), (-0.3, -0.2, 2.3)], 0.04, M("bark"), seg=6)) + p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=9, seed=7) + p += _shrub("CrownTop", (0.05, 0.05, 3.15), (0.6, 0.6, 0.5), M("foliage_dark"), + lumps=5, seed=11) + p.append(box("Grate", (1.0, 1.0, 0.02), (0, 0, 0.01), M("dark_metal"))) + elif name == "palm_tree": # ~7 m Hollywood palm + prof = [] + for i in range(12): + z = i * 0.6 + prof.append((0.17 - i * 0.006 + (0.012 if i % 2 else 0.0), z)) + trunk = lathe("Trunk", prof, M("palm_bark"), seg=8) + for v in trunk.data.vertices: # gentle lean + v.co.x += 0.04 * (v.co.z / 6.6) ** 2 * 6.6 + 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 + 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) + mid = (top[0] + math.cos(a) * 1.0, top[1] + math.sin(a) * 1.0, top[2] + 0.35) + 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] + vs = [] + for pt, wd in zip(pts, widths): + vs.append((bm.verts.new(pt - side * wd + Vector((0, 0, -0.06))), + bm.verts.new(pt + Vector((0, 0, 0.04))), + bm.verts.new(pt + side * wd + Vector((0, 0, -0.06))))) + for (a0, a1, a2), (b0, b1, b2) in zip(vs, vs[1:]): + bm.faces.new((a0, a1, b1, b0)) + bm.faces.new((a1, a2, b2, b1)) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) + frond = _link("Frond", bm) + _finish(frond, M("palm_leaf"), smooth=True, sharp_angle=80) + p.append(frond) + elif name == "round_planter": # mockup pot with topiary, ~1.0 m + p.append(lathe("Pot", [(0.0, 0.0), (0.22, 0.0), (0.26, 0.05), (0.33, 0.50), + (0.36, 0.52), (0.36, 0.58), (0.31, 0.58), (0.0, 0.56)], + M("stone_pot"), seg=20, sharp=30)) + p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=7, seed=5) + elif name == "a_frame_sign": # chalkboard sidewalk sign + for s in (-1.0, 1.0): + tilt = math.radians(14) * s + y = 0.13 * s + p.append(box("Frame", (0.62, 0.035, 0.98), (0, y, 0.48), M("dark_wood"), + rot=(tilt, 0, 0), bevel=0.008)) + p.append(box("Board", (0.52, 0.04, 0.82), (0, y, 0.50), M("chalkboard"), + rot=(tilt, 0, 0))) + for i, z in enumerate((0.72, 0.60, 0.40)): + p.append(box("Chalk", (0.38 - 0.06 * i, 0.044, 0.05), + (0, y + 0.0005 * s, z), M("chalk"), rot=(tilt, 0, 0))) + p.append(cyl("Hinge", 0.012, 0.012, 0.62, (0, 0, 0.955), M("steel"), seg=8, + rot=(0, math.radians(90), 0))) + elif name == "wall_lantern": # wall-mounted, origin on wall at mount height + p.append(box("Plate", (0.14, 0.03, 0.22), (0, 0.0, 0.0), M("black_metal"), bevel=0.01)) + 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))) + 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)], + M("black_metal"), seg=8, loc=(0, -0.24, 0.0))) + elif name == "traffic_cone": + p.append(box("Base", (0.38, 0.38, 0.04), (0, 0, 0.02), M("rubber"), bevel=0.02)) + p.append(lathe("Cone", [(0.15, 0.04), (0.035, 0.68), (0.0, 0.69)], M("cone_orange"), + seg=16)) + p.append(lathe("Stripe", [(0.118, 0.22), (0.096, 0.34)], M("reflective"), seg=16)) + p.append(lathe("Stripe2", [(0.08, 0.42), (0.064, 0.50)], M("reflective"), seg=16)) + elif name == "crowd_barrier": # steel bike-rack barricade, 2.2 m + p.append(tube("Frame", [(-1.1, 0, 0.12), (-1.1, 0, 1.05), (1.1, 0, 1.05), (1.1, 0, 0.12), + (-1.1, 0, 0.12)], 0.022, M("steel"), seg=8, caps=False)) + for i in range(13): + x = -0.96 + i * 0.16 + p.append(cyl("Bar", 0.01, 0.01, 0.90, (x, 0, 0.585), M("steel"), seg=6)) + for x in (-1.0, 1.0): + p.append(tube("Foot", [(x, -0.30, 0.0), (x, 0.0, 0.14), (x, 0.30, 0.0)], 0.018, + M("steel"), seg=6)) + elif name == "stanchion": # queue post with velvet rope stub + p.append(lathe("Base", [(0.0, 0.0), (0.17, 0.0), (0.17, 0.025), (0.04, 0.05)], + M("chrome"), seg=20)) + p.append(cyl("Post", 0.025, 0.025, 0.90, (0, 0, 0.50), M("chrome"), seg=12)) + p.append(sphere("Top", 0.04, (0, 0, 0.97), M("chrome"), seg=12, rings=8)) + p.append(tube("Rope", [(0, 0.0, 0.92), (0.2, 0.0, 0.80), (0.45, 0.0, 0.74), + (0.70, 0.0, 0.80), (0.9, 0.0, 0.92)], 0.018, + M("velvet_red"), seg=8)) + p.append(cyl("Hook", 0.022, 0.022, 0.05, (0.9, 0, 0.92), M("brass"), seg=8, + rot=(0, math.radians(90), 0))) + elif name == "handrail": # 2 m stair rail, posts at x=0 and x=2 (stairs run +X up) + p.append(tube("Rail", [(-0.1, 0, 0.92), (0.0, 0, 0.95), (2.0, 0, 1.75), (2.1, 0, 1.76)], + 0.025, M("black_metal"), seg=10)) + for x, z in ((0.0, 0.0), (1.0, 0.4), (2.0, 0.8)): + p.append(cyl("Post", 0.022, 0.022, 0.95 + 0.0, (x, 0, z + 0.475 + (x * 0.0)), + M("black_metal"), seg=8)) + p.append(cyl("Flange", 0.05, 0.05, 0.015, (x, 0, z + 0.008), M("black_metal"), seg=10)) + elif name == "security_podium": # check-in lectern for the door + p.append(box("Plinth", (0.62, 0.46, 0.06), (0, 0, 0.03), M("plastic_black"), + bevel=0.01)) + p.append(box("Body", (0.56, 0.40, 1.0), (0, 0, 0.56), M("dark_wood"), bevel=0.015)) + p.append(box("Desk", (0.64, 0.48, 0.04), (0, 0.02, 1.10), M("dark_wood"), + rot=(math.radians(-12), 0, 0), bevel=0.01)) + p.append(box("Panel", (0.44, 0.01, 0.62), (0, -0.205, 0.55), M("black_metal"))) + p.append(box("Badge", (0.18, 0.012, 0.18), (0, -0.212, 0.66), M("patch"), bevel=0.01)) + p.append(box("Tablet", (0.26, 0.18, 0.012), (0, 0.0, 1.13), M("screen"), + rot=(math.radians(-12), 0, 0))) + elif name == "double_door": # 1.8 x 2.6 framed entrance doors + p.append(box("FrameL", (0.10, 0.20, 2.70), (-0.95, 0, 1.35), M("door_paint"), bevel=0.01)) + p.append(box("FrameR", (0.10, 0.20, 2.70), (0.95, 0, 1.35), M("door_paint"), bevel=0.01)) + p.append(box("Header", (2.0, 0.20, 0.12), (0, 0, 2.66), M("door_paint"), bevel=0.01)) + for s in (-1.0, 1.0): + x = 0.45 * s + p.append(box("Leaf", (0.88, 0.06, 2.58), (x, 0.03, 1.30), M("door_paint"), + bevel=0.008)) + p.append(box("Glass", (0.62, 0.065, 1.50), (x, 0.03, 1.55), M("dark_glass"))) + p.append(box("Kick", (0.80, 0.07, 0.22), (x, 0.03, 0.14), M("steel"))) + p.append(cyl("Pull", 0.014, 0.014, 0.50, (0.07 * s, -0.06, 1.15), M("chrome"), + seg=8)) + p.append(box("Reader", (0.09, 0.03, 0.14), (1.12, -0.06, 1.25), M("plastic_black"), + bevel=0.008)) + p.append(box("LockLed", (0.03, 0.01, 0.03), (1.12, -0.078, 1.29), M("lock_led"))) + elif name == "marquee": # canopy sign for the venue, 6 m wide, origin at wall base + p.append(box("Canopy", (6.0, 1.6, 0.18), (0, -0.80, 0.0), M("black_metal"), bevel=0.02)) + p.append(box("SignBox", (5.4, 0.35, 1.0), (0, -1.45, 0.60), M("black_metal"), + bevel=0.03)) + p.append(box("SignFace", (4.8, 0.02, 0.62), (0, -1.63, 0.62), M("sign_face"))) + for i in range(24): + x = -2.6 + i * (5.2 / 23) + for z in (0.17, 1.06): + p.append(sphere("Bulb", 0.035, (x, -1.635, z), M("bulb"), seg=8, rings=4)) + for i in range(10): + x = -2.7 + i * 0.6 + p.append(sphere("Under", 0.03, (x, -1.0, -0.10), M("bulb"), seg=6, rings=4)) + for s in (-1.0, 1.0): + p.append(tube("Rod", [(2.6 * s, -1.5, 1.05), (2.6 * s, -0.4, 1.9)], 0.02, + M("black_metal"), seg=6)) + elif name == "sedan": # ~4.6 m dark sedan, front toward -Y + p.append(box("Lower", (1.80, 4.60, 0.62), (0, 0, 0.52), M("car_paint"), bevel=0.12, + segs=3)) + cabin = box("Cabin", (1.56, 2.30, 0.55), (0, 0.25, 1.05), M("car_paint"), bevel=0.10, + segs=2) + for v in cabin.data.vertices: # taper the greenhouse + if v.co.z > 1.1: + v.co.x *= 0.88 + v.co.y = 0.25 + (v.co.y - 0.25) * 0.72 + p.append(cabin) + glass = box("Glass", (1.58, 2.10, 0.36), (0, 0.25, 1.06), M("dark_glass"), bevel=0.06) + for v in glass.data.vertices: + if v.co.z > 1.1: + v.co.x *= 0.89 + v.co.y = 0.25 + (v.co.y - 0.25) * 0.74 + p.append(glass) + for x in (-0.80, 0.80): + for y in (-1.45, 1.45): + p.append(cyl("Tyre", 0.34, 0.34, 0.24, (x, y, 0.34), M("rubber"), seg=18, + rot=(0, math.radians(90), 0))) + p.append(cyl("Rim", 0.22, 0.22, 0.25, (x, y, 0.34), M("chrome"), seg=12, + rot=(0, math.radians(90), 0))) + for x in (-0.62, 0.62): + p.append(box("Headlight", (0.38, 0.04, 0.10), (x, -2.29, 0.66), M("headlight"), + bevel=0.02)) + p.append(box("Tail", (0.40, 0.04, 0.10), (x, 2.29, 0.70), M("lock_led"), + bevel=0.02)) + p.append(box("Grille", (0.70, 0.03, 0.14), (0, -2.30, 0.55), M("chrome"), bevel=0.02)) + else: + raise KeyError(name) + obj = join(p, name) + uv_unwrap(obj) + return [obj] + + +# --------------------------------------------------------------------------- +# Build + render entry points (inside Blender) +# --------------------------------------------------------------------------- + +def _build_one(category, name): + reset_scene() + if category == "npc": + objs = build_character(name) + elif category == "npc_gear": + objs = build_gear(name) + else: + objs = build_prop(name) + path = export(objs, category, name) + print(f"wrote {path.relative_to(ROOT)}") + return path + + +def _selected(only): + wanted = {n.strip() for n in only.split(",") if n.strip()} if only else None + return [(c, n) for c, n in catalogue() if wanted is None or n in wanted] + + +def _setup_render(objs, out_path, character=False): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 48 + scene.cycles.use_denoising = True + scene.render.resolution_x = 640 + scene.render.resolution_y = 640 + scene.render.film_transparent = False + scene.view_settings.view_transform = "AgX" + world = bpy.data.worlds.new("World") + world.use_nodes = True + bg = world.node_tree.nodes.get("Background") + bg.inputs["Color"].default_value = (0.42, 0.44, 0.48, 1.0) + bg.inputs["Strength"].default_value = 0.7 + scene.world = world + + mins = Vector((1e9, 1e9, 1e9)) + maxs = Vector((-1e9, -1e9, -1e9)) + for o in objs: + if o.type != "MESH" or o.name.startswith("Icosphere") or o.hide_render: + continue + for corner in o.bound_box: + w = o.matrix_world @ Vector(corner) + mins = Vector(map(min, mins, w)) + maxs = Vector(map(max, maxs, w)) + center = (mins + maxs) / 2 + size = max((maxs - mins).length, 0.05) + + bpy.ops.mesh.primitive_plane_add(size=max(size * 6, 2.0), location=(0, 0, min(mins.z, 0))) + floor = bpy.context.object + floor.data.materials.append(material("concrete")) + + sun_data = bpy.data.lights.new("Sun", "SUN") + sun_data.energy = 3.5 + sun_data.angle = math.radians(8) + sun = bpy.data.objects.new("Sun", sun_data) + sun.rotation_euler = (math.radians(50), 0, math.radians(-35)) + scene.collection.objects.link(sun) + fill_data = bpy.data.lights.new("Fill", "AREA") + fill_data.energy = 40.0 * size * size + fill_data.size = size * 2 + fill = bpy.data.objects.new("Fill", fill_data) + fill.location = center + Vector((size * 1.2, -size * 1.4, size * 0.6)) + fill.rotation_euler = (Vector((0, 0, 0)) - (fill.location - center)).to_track_quat( + "-Z", "Y").to_euler() + scene.collection.objects.link(fill) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50 + cam = bpy.data.objects.new("Cam", cam_data) + direction = Vector((0.55, -1.0, 0.32 if not character else 0.12)).normalized() + cam.location = center + direction * size * 1.45 + cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler() + scene.collection.objects.link(cam) + scene.camera = cam + scene.render.filepath = str(out_path) + + +def _render_one(category, name): + path = asset_path(category, name) + if not path.is_file(): + print(f"skip {name}: not built") + return + reset_scene() + bpy.ops.import_scene.gltf(filepath=str(path)) + 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 + PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + out = PREVIEW_DIR / f"{name}.png" + _setup_render(objs, out, character=category == "npc") + bpy.ops.render.render(write_still=True) + print(f"rendered {out.relative_to(ROOT)}") + + +# --------------------------------------------------------------------------- +# Audit + contact sheets (stdlib / Pillow, outside Blender) +# --------------------------------------------------------------------------- + +def _read_glb(path): + data = path.read_bytes() + magic, version, length = struct.unpack_from(" CHAR_MAX_TRIS: + res["issues"].append(f"triangles {tris} > {CHAR_MAX_TRIS}") + if verts > CHAR_MAX_VERTS: + res["issues"].append(f"vertices {verts} > {CHAR_MAX_VERTS}") + if verts and abs(mins[1]) > 0.03: + res["issues"].append(f"feet not on the ground (min y {mins[1]:.3f})") + if verts and not (1.6 < maxs[1] < 2.1): + res["issues"].append(f"unexpected height {maxs[1]:.2f}") + elif category == "npc_gear": + if tris > GEAR_MAX_TRIS: + res["issues"].append(f"triangles {tris} > {GEAR_MAX_TRIS}") + else: + if tris > PROP_MAX_TRIS: + res["issues"].append(f"triangles {tris} > {PROP_MAX_TRIS}") + grounded = name not in ("wall_lantern", "marquee") + if grounded and verts and abs(mins[1]) > 0.03: + res["issues"].append(f"not on the ground (min y {mins[1]:.3f})") + if not (SRC_DIR / f"{name}.blend").is_file(): + res["issues"].append("editable .blend source missing") + res["ok"] = not res["issues"] + return res + + +def write_manifest(results): + out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH, + "assets": {}} + for r in results: + entry = {"path": str(asset_path(r["category"], r["name"]).relative_to(ROOT)), + "category": r["category"], "triangles": r["triangles"], + "vertices": r["vertices"], "materials": r["materials"], + "bounds_godot": r["bounds"]} + if r["category"] == "npc": + spec = CHARACTERS[r["name"]] + entry.update({"layer": spec["layer"], "roles": spec["roles"], + "tint_slots": SLOTS, "mesh_node": "npc_base"}) + elif r["category"] == "npc_gear": + entry["attach"] = GEAR[r["name"]] + else: + entry.update({k: v for k, v in PROPS[r["name"]].items()}) + out["assets"][r["name"]] = entry + MANIFEST.parent.mkdir(parents=True, exist_ok=True) + MANIFEST.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def contact_sheets(): + from PIL import Image, ImageDraw + groups = {} + for cat, name in catalogue(): + groups.setdefault(cat, []).append(name) + written = [] + for cat, names in groups.items(): + tiles = [(n, PREVIEW_DIR / f"{n}.png") for n in names + if (PREVIEW_DIR / f"{n}.png").is_file()] + if not tiles: + continue + cols = 4 if len(tiles) > 4 else len(tiles) + rows = (len(tiles) + cols - 1) // cols + cell = 320 + sheet = Image.new("RGB", (cols * cell, rows * (cell + 24)), (24, 26, 30)) + draw = ImageDraw.Draw(sheet) + for i, (n, path) in enumerate(tiles): + with Image.open(path) as im: + im = im.convert("RGB").resize((cell, cell), Image.Resampling.LANCZOS) + 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) + written.append(out) + print(f"wrote {out.relative_to(ROOT)}") + return written + + +def main(argv=None): + raw = list(argv if argv is not None else sys.argv[1:]) + if "--" in raw: + raw = raw[raw.index("--") + 1:] + ap = argparse.ArgumentParser(description="Blender asset builder") + sub = ap.add_subparsers(dest="cmd", required=True) + for cmd in ("build", "render"): + s = sub.add_parser(cmd) + s.add_argument("--only", default="") + a = sub.add_parser("audit") + a.add_argument("--json", action="store_true") + sub.add_parser("sheet") + sub.add_parser("list") + args = ap.parse_args(raw) + + if args.cmd in ("build", "render"): + if bpy is None: + print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender " + f"--background --python {Path(__file__).resolve()} -- {args.cmd}", + file=sys.stderr) + return 2 + for cat, name in _selected(args.only): + (_build_one if args.cmd == "build" else _render_one)(cat, name) + if args.cmd == "build": + results = [audit_asset(c, n) for c, n in catalogue()] + write_manifest([r for r in results if r["vertices"]]) + bad = [r for r in results if not r["ok"] and r["name"] in + {n for _, n in _selected(args.only)}] + for r in bad: + print("BAD", r["name"], r["issues"]) + return 1 if bad else 0 + return 0 + if args.cmd == "list": + for cat, name in catalogue(): + extra = GEAR.get(name, "") if cat == "npc_gear" else \ + CHARACTERS.get(name, {}).get("layer", "") if cat == "npc" else \ + PROPS[name].get("replaces", "new") + print(f"{cat:9s} {name:20s} {extra}") + return 0 + if args.cmd == "sheet": + contact_sheets() + return 0 + results = [audit_asset(c, n) for c, n in catalogue()] + if args.json: + print(json.dumps(results, indent=2)) + else: + for r in results: + print(f"{'OK ' if r['ok'] else 'BAD'} {r['category']:9s} {r['name']:20s} " + f"tris={r['triangles']:5d} verts={r['vertices']:5d}") + for issue in r["issues"]: + print(" -", issue) + return 0 if all(r["ok"] for r in results) else 1 + + +if __name__ == "__main__": + sys.exit(main())