Lane E (assets): pipeline, manifest + 23 on-device facade skins
Audit -> normalize (16 GLBs + thumbs) -> web/assets/manifest.json (the contract Lanes B/C/D read) -> facade gap-fill. Step 4 facades generated locally on the M3 Ultra via MODELBEAST flux_local (FLUX.2-klein-4B / MLX on MPS): 23 skins, free, ~5.7s each, blank signboard, eyeballed, harvested, mapped to shop types. Every registry type now has >=3 facades (stall 2->4, closing the Lane A gap). Corner-lot -side walls tagged face:"side" with empty types so no shop front ever selects a windowless wall. manifest validates green. Still gated on John (both optional -- the game runs asset-free): publish the 16 GLBs to 3GOD (needs GOD3_PW), MeshGod hero props (~$2.70). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2
.gitignore
vendored
@ -2,6 +2,8 @@
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.genraw/
|
||||
pipeline/.genraw/
|
||||
pipeline/_normalized/
|
||||
saves/
|
||||
node_modules/
|
||||
.env
|
||||
|
||||
150
E-progress.md
Normal file
@ -0,0 +1,150 @@
|
||||
# LANE E — ASSETS · progress (PROCITY-E)
|
||||
|
||||
*2026-07-14. Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`, `web/assets/manifest.json`.
|
||||
Reviewed against `docs/LANES/LANE_E_ASSETS.md` acceptance. Nothing in other lanes' files was touched.*
|
||||
|
||||
## Status at a glance
|
||||
|
||||
| Step | Deliverable | State |
|
||||
|---|---|---|
|
||||
| 1 Audit | `pipeline/AUDIT.md` (+ `glb_stat.py`, `_audit_stats.json`) | ✅ done |
|
||||
| 2 Normalize | `pipeline/normalize.py` → **16 GLBs** + 16 thumbs, verified | ✅ done (local); ⛔ **publish gated on `GOD3_PW`** |
|
||||
| 3 Manifest | `web/assets/manifest.json` + `validate_manifest.py` (+ `build_manifest.py`) | ✅ done, validates green |
|
||||
| 4 Facade skins | `gen_skins.py` **`--local`** backend + 23-skin gap pack | ✅ **DONE — 23 skins generated on-device, free, harvested + mapped** |
|
||||
| 5 Hero props | `MESHGOD_BATCH.md` + `meshgod_batch.json` (9 solids) | ✅ spec'd; ⛔ **not run (John's go, ~$2.70)** |
|
||||
| — | `pipeline/README.md` + contact sheets in `docs/shots/laneE/` | ✅ done |
|
||||
|
||||
**Step 4 no longer costs anything.** The 23 facades were generated **locally on the M3 Ultra**
|
||||
via MODELBEAST `flux_local` (FLUX.2-klein-4B on MPS) — free, ~5.7s each, weights already cached.
|
||||
No OpenRouter key, no ~9¢ spend. See "Step 4 (done, local)" below.
|
||||
|
||||
**Two things still need John (both optional — the game runs asset-free):** (1) the depot password
|
||||
`GOD3_PW` to publish the 16 GLBs live; (2) a go/no-go on the paid **hero-prop** step (5 ≈ $2.70).
|
||||
|
||||
## What landed
|
||||
|
||||
### Step 1 — Audit
|
||||
- `pipeline/glb_stat.py`: dependency-free GLB parser (tris, bbox, texture px, Draco) — runs on
|
||||
ultra's py3.9. Full library stats → `pipeline/_audit_stats.json`.
|
||||
- `pipeline/AUDIT.md`: the library is a raw dump — almost nothing obeys the GLB law (only
|
||||
`record_crate_plastic_01` was native-metres; scale/orientation arbitrary; tris 12→960k; textures
|
||||
to 4096; several **branded/trademarked** assets quarantined). Candidate→PROCITY-id table with
|
||||
status, coverage-vs-registry table, QUARANTINE list, depot cross-reference.
|
||||
- Toolchain reality documented (Blender present on ultra but off PATH; no coreutils/timeout).
|
||||
|
||||
### Step 2 — Normalize & publish
|
||||
- `pipeline/normalize.py`: headless Blender, extends `MESHGOD/finish_glb.py` (solidify/decimate/
|
||||
bright/base-origin) + adds **height-normalize, outlier cleanup, texture→WebP≤1024, per-asset
|
||||
up/yaw, 256px thumbnail**. Driven by `pipeline/batch.json`.
|
||||
- **16 assets** normalized to house law (metres, +Y up, base origin, ≤5k tris, WebP ≤1024, no
|
||||
Draco). Every one **eyeballed** — Y-up confirmed by re-importing exports through `render_glb.py`.
|
||||
Footprints from Blender-space `_normalize_results.json`. Thumbs in `web/assets/thumbs/`.
|
||||
- fittings (9): record_crate, wire_shelf, clothes_rack, bookshelf, cube_shelf_wide, counter,
|
||||
coffee_table, work_table, box_crate
|
||||
- furniture (7): bench, bench_modern, bench_wood, longbench, park_bench, streetlight, food_cart
|
||||
- `pipeline/publish.py`: mirrors the meshgod→3GOD contract exactly (cookie `hmac(GOD3_PW,"3god-ok")`,
|
||||
`POST /api/upload` + `/api/thumb`). Reads the secret from env — never contains/logs it. `--dry-run`
|
||||
verified all 16 ready. **Not published yet: `GOD3_PW` isn't set here.** Run: `export GOD3_PW=… &&
|
||||
python3 pipeline/publish.py` (idempotent, reversible).
|
||||
|
||||
### Step 3 — Manifest
|
||||
- `web/assets/manifest.json` (v1): the contract Lanes B/C/D read. All **69 skins** catalogued
|
||||
(25 facade / 10 sky / 10 ground / 8 wallpaper / 6 floor / 7 surface / 3 awning) + 16 fittings/
|
||||
furniture with **footprints, heights, thumbs**. `conventions` block documents the GLB law,
|
||||
blank-signboard rule, corner-lot policy, fallbacks, and depot load path.
|
||||
- Every registry shop type has **≥2 facades** (verified in the validator). Facades tagged
|
||||
`signboard: blank|baked` — 5 djsim/market/grimy skins carry baked sign text and must not get a
|
||||
name overlay; the other 20 are clean overlay targets.
|
||||
- `pipeline/validate_manifest.py`: Lane F gate — parses JSON, checks every skin exists, every GLB
|
||||
is local-or-on-depot, thumbs exist, footprints/heights sane, ≥2 facades/type. **Green** (16
|
||||
local-only warnings until publish; `--depot` enforces live).
|
||||
- `pipeline/build_manifest.py`: reproducible generator (curated type-maps + derived skin/fitting
|
||||
lists) so the manifest regenerates when assets change.
|
||||
|
||||
### Step 4 — Facade skin expansion (DONE, local)
|
||||
- `pipeline/gen_skins.py`: ported from `thriftgod/gen_assets.py` — **same STYLE bible + blank-
|
||||
signboard template** so new skins match. Resumable `.genraw/`, `--dry-run`/`--only`/`--harvest`.
|
||||
- **`--local` backend (the one I used):** MODELBEAST `flux_local` → FLUX.2-klein-4B on the M3
|
||||
Ultra's MPS GPU. Apache/**ungated**, weights already cached (15 GB), **free**, ~5.7 s/img, one
|
||||
GPU job at a time. Direct subprocess to `~/Documents/MODELBEAST/server/operators/flux_local/run.py`
|
||||
(`--outdir`/`--params` JSON) — no server, no cloud, no key. Cloud OpenRouter path kept as fallback.
|
||||
- **All 23 generated → harvested → mapped.** 0 failures, 2:11 total. Eyeballed every one
|
||||
(contact sheet `docs/shots/laneE/facades_gen_local.png`): every shopfront has a **genuinely
|
||||
blank signboard** (Lane B name-overlay target), style indistinguishable from the 25 anchors.
|
||||
The pack: toy (2), book (2), video (2), milkbar (3), wide dept anchors (2), stall/market (2),
|
||||
warehouse (2), arcade portal (1), residential house fronts (4), corner-lot `-side` walls (3).
|
||||
- **Coverage after mapping** (`build_manifest.py` `FACADE_TYPES`, mirrors registry.js + additive):
|
||||
record 3 · opshop 3 · toy 5 · book 5 · video 4 · pawn 7 · milkbar 5 · dept 4 · **stall 4**
|
||||
— every registry type ≥3. **`stall` went 2→4**, retiring the Lane A single-facade gap.
|
||||
- **Corner-lot `-side` skins now exist** (brick/render/weatherboard): tagged `face:"side"` with
|
||||
empty `types` in the manifest so no shop-front pool ever picks a windowless wall; Lane B maps
|
||||
them to the exposed side face (`conventions.corner_lots` + new `conventions.facade_face`).
|
||||
- `pipeline/SKIN_PROMPTS.md`: identical pack kept for the free manual flow (fallback if MODELBEAST
|
||||
is ever down).
|
||||
|
||||
### Step 5 — Bespoke hero props
|
||||
- `pipeline/meshgod_batch.json` + `MESHGOD_BATCH.md`: 9 solids (glass case, fridge, till, listening
|
||||
booth, arcade cabinet, magazine rack, milkshake mixer, novelty record, bus shelter) in the
|
||||
thriftgod_hero pattern. Documents the **MeshGod gotcha** (thin wires/flat discs → primitives),
|
||||
reuse-first (cash-register/glass-case likely already on depot), and the primitive fallbacks.
|
||||
**Not run** — gated on John (~$2.70). Nothing blocks: all have in-engine fallbacks.
|
||||
|
||||
## Adversarial review & fixes (self-run, 4-dimension × verify)
|
||||
|
||||
Ran a 19-agent adversarial review over all deliverables (correctness / contract-fit / doc-accuracy /
|
||||
house-law), each finding independently verified. **15 confirmed** (1 blocker, 5 major, 9 minor).
|
||||
All fixed and re-verified:
|
||||
|
||||
- **BLOCKER — off-origin exports.** `normalize.py` joined meshes but never flattened parent node
|
||||
hierarchies, so assets under a parent empty shipped off-origin: `park_bench` was **~1 km off-origin,
|
||||
floating 30 m**; `food_cart` 15 m off; `clothes_rack` sank 0.19 m. My earlier spot-check missed it
|
||||
(the clean assets have no hierarchy). **Fixed:** `parent_clear(KEEP_TRANSFORM)` + drop non-mesh
|
||||
nodes before join. Re-verified node-aware: all 16 now `center=[0, h/2, 0]`, **minY=0.0**.
|
||||
- **Major — tri budget counted faces, not triangles** (latent for quad FBX/OBJ). Fixed: triangulate
|
||||
in `clean_mesh` so the ≤5k gate + reported counts are true triangles.
|
||||
- **Major — fitting-id namespace.** Added `fittingAliases` (registry semantic id → physical id) to
|
||||
the manifest. (Lane C's `glb.js` already maps its kinds to my physical ids, so it worked — this
|
||||
makes it explicit + future-proof.)
|
||||
- **Major — facade map vs registry.js.** Aligned my facade→type map to be a consistent superset of
|
||||
Lane A's `registry.js`; flagged that registry's `stall` pool has only 1 facade (Lane A gap).
|
||||
- **Major — facing -Z not enforced.** Corrected the docs: symmetric props are orientation-agnostic;
|
||||
no auto-facing (per-asset `yaw` available). Removed the false "enforced" claim.
|
||||
- **Major — licence provenance.** Strengthened the publish-set caveat (record per-asset provenance
|
||||
before pushing to the shared CDN) — a reason publish stays gated on John.
|
||||
- **Minors:** hardened `glb_stat.py` (node-aware world bounds + WebP size sniff — the exact blind
|
||||
spots that hid the blocker); `validate_manifest.py` liveness now uses a Range GET not HEAD;
|
||||
Akai-MPC trademark flag; corrected audit counts (59 parsed `.glb`, rejects 8, food_cart tris).
|
||||
|
||||
## Acceptance check (LANE_E)
|
||||
|
||||
- ✅ `AUDIT.md` complete.
|
||||
- ✅ ≥15 normalized GLBs (**16**) with thumbnails — ⛔ *live on 3GOD pending publish (needs cred)*.
|
||||
- ✅ `manifest.json` validates green; fittings/furniture carry footprints for B/C to place untouched.
|
||||
- ✅ Every registry shop type has ≥2 facades — now **≥3** (48 facades total). New skins
|
||||
**generated** (not just planned) and eyeball-matched to the anchor style
|
||||
(`facades_style_anchor.png` = 25 originals; `facades_gen_local.png` = 23 new local skins;
|
||||
`fittings_normalized.png` = the 16 GLBs — all in `docs/shots/laneE/`).
|
||||
- ✅ One-page `pipeline/README.md` (re-run each step, costs, where creds live).
|
||||
|
||||
## Notes for the other lanes
|
||||
|
||||
- **Lane B / C:** read `web/assets/manifest.json`. `fittings`/`furniture` → `depot:<file>` via
|
||||
`core/loaders.js` (placeholder box on miss — the game runs asset-free). Footprints are `[w,d]`
|
||||
metres on the ground; heights in metres; all base-origin, +Y up, facing −Z.
|
||||
- **Lane B:** overlay shop names only on facades with `signboard:"blank"`. Wide `dept-*` anchors
|
||||
(when generated) want a wider UV plane. `awning`/`ground`/`sky` skins are yours; `wall`/`interior`
|
||||
are Lane C's.
|
||||
- **Lane F:** wire `python3 pipeline/validate_manifest.py` into the gate (add `--depot` post-publish).
|
||||
|
||||
## Open questions for Fable/John
|
||||
|
||||
1. **Publish now?** I have 16 verified GLBs staged in `pipeline/_normalized/` ready to push to the
|
||||
shared depot. It's an outward-facing write to shared infra + needs `GOD3_PW`, so I left it for
|
||||
your go rather than assume it.
|
||||
2. ~~Spend the ~9¢ on facades?~~ **Resolved — done for free on-device** (MODELBEAST flux_local).
|
||||
Only the **~$2.70 hero-prop** MeshGod batch (Step 5) still wants a go/no-go; all props have
|
||||
in-engine primitive fallbacks, so it's a polish upgrade, not a blocker. *(Note: image→GLB is
|
||||
also local on this box via `sf3d`/`trellis_mac`, but those weights are HF-gated — a setup
|
||||
step before that path is free too.)*
|
||||
3. A few library assets were near-misses (garage_shelves, radiogram, market_stall — see AUDIT
|
||||
rejects). Worth a manual Blender re-origin pass later, or leave to primitives? I left them out.
|
||||
0
docs/shots/laneE/.gitkeep
Normal file
BIN
docs/shots/laneE/facades_gen_local.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/shots/laneE/facades_style_anchor.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/shots/laneE/fittings_normalized.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
135
pipeline/AUDIT.md
Normal file
@ -0,0 +1,135 @@
|
||||
# PROCITY Asset Audit — Lane E, Step 1
|
||||
|
||||
*Generated 2026-07-14 from a live inspection of `johnking@100.91.239.7:~/Documents/3D=models/`
|
||||
(the sorted house library) + the 3GOD depot `GET /api/list`. Tri counts / bounding boxes from
|
||||
`pipeline/glb_stat.py` (dependency-free GLB parser); orientation & final scale verified by
|
||||
rendering every candidate through Blender and looking at the thumbnails (never "should look right").*
|
||||
|
||||
## TL;DR
|
||||
|
||||
- The library is a **raw dump**: almost nothing obeys the house GLB law. Scale is arbitrary
|
||||
(only `record_crate_plastic_01` was native-metres), up-axis varies, tri counts span **12 → 960k**,
|
||||
textures reach **4096px**, and several assets carry **branded/trademarked designs** (quarantined).
|
||||
- `pipeline/normalize.py` fixes scale/orientation/tris/textures uniformly. **16 assets** normalized
|
||||
clean, verified upright & metre-scaled, WebP ≤1024, ≤5k tris, +Y up, base origin — ready to publish.
|
||||
- Gaps with **no usable source** (→ `MESHGOD_BATCH.md` or in-engine primitives): glass display
|
||||
case, fridge, till/register, spinner rack, magazine rack, market stall, escalator, VHS shelving.
|
||||
- Depot already holds a few reusable house props (`street-bench`, `council-bin`, `milk-crate-stack`)
|
||||
and 138 assets total (mostly TRELLIS `*-views-3d-*` / `image-3d-*` MeshGod outputs).
|
||||
- **Skin caveat:** 5 of the 25 facades carry **baked-in sign text** (`djsim-milkbar` "MILK BAR",
|
||||
`djsim-video`, `djsim-pub`, `market`, `grimy`) — they break the blank-signboard convention, so
|
||||
the manifest tags them `signboard:"baked"` and Lane B must not overlay a name on them. The other
|
||||
20 are genuinely blank.
|
||||
|
||||
## Toolchain reality (differs from the lane brief)
|
||||
|
||||
| thing | brief said | actual |
|
||||
|---|---|---|
|
||||
| Blender on ultra | "runs on ultra against Blender" | present but **not on PATH** → `/Applications/Blender.app/Contents/MacOS/Blender` (v5.0.1). Renders 4 angles in ~0.9s. |
|
||||
| coreutils / timeout | none | confirmed — used ssh `ConnectTimeout`, no `timeout`/`setsid` |
|
||||
| python on ultra | 3.9 | 3.9.6 — `glb_stat.py`/`normalize.py` written to that floor |
|
||||
| OpenRouter key | `thriftgod/.env` | absent; house convention `~/Documents/dealgod/lore/keys.env` (auto-read by gen scripts) |
|
||||
| depot upload cred | — | `GOD3_PW` env, **not set here** → publish is gated on John (see `publish.py`) |
|
||||
|
||||
## Normalized set (16 — ready; `manifest.json` → `fittings` / `furniture`)
|
||||
|
||||
house law enforced on every one: metres · +Y up · origin at base (minY=0, centred X/Z — verified
|
||||
node-aware) · ≤5k tris · WebP ≤1024 · no Draco. *Facing is not auto-corrected* — symmetric props are
|
||||
orientation-agnostic; the few directional ones (counter, food_cart) keep their native front and
|
||||
Lane B/C rotate when placing (per-asset `yaw` is available in `batch.json`).
|
||||
|
||||
| PROCITY id | source GLB | src tris → out | footprint (m) | height (m) | notes |
|
||||
|---|---|---|---|---|---|
|
||||
| **fit** record_crate | shop-fittings/record_crate_plastic_01 | 18320→5000 | 0.46 × 0.63 | 0.41 | ★ native-metres, textured red milk crate; serves record bin too |
|
||||
| **fit** wire_shelf | shop-fittings/shelf_metal_wire_01 | 330→330 | 1.17 × 0.44 | 1.80 | ★ clean upright wire shelving |
|
||||
| **fit** clothes_rack | shop-fittings/clothes_rack_01 | 3152→3152 | 1.34 × 0.43 | 1.50 | ★ rolling clothes rack (opshop) |
|
||||
| **fit** bookshelf | shop-fittings/bookshelves | 45128→5000 | 0.20 × 1.02 | 2.00 | tall bookshelf w/ books (book/video/opshop) |
|
||||
| **fit** cube_shelf_wide | shop-fittings/shelf_cube_bookshelf_01 | 17734→4999 | 0.75 × 1.24 | 1.10 | stocked cube shelving (toy/book) |
|
||||
| **fit** counter | shop-fittings/balcao | 276→276 | 1.06 × 4.02 | 1.10 | long bar counter — Lane C may want to scale to lot |
|
||||
| **fit** coffee_table | shop-fittings/coffeetable | 495→495 | 0.67 × 1.49 | 0.45 | low display/side table |
|
||||
| **fit** work_table | shop-fittings/computertable | 1556→1556 | 0.44 × 1.04 | 0.75 | work/trestle table w/ lower shelf |
|
||||
| **fit** box_crate | shop-fittings/warehouse_box | 78→78 | 1.35 × 1.32 | 0.60 | open bulk bin / bric-a-brac box |
|
||||
| **street** bench | street-furniture/bench | 1942→1942 | 0.44 × 1.21 | 0.85 | slatted seat |
|
||||
| **street** bench_modern | street-furniture/eb_hipster_bench | 640→640 | 1.68 × 0.47 | 0.45 | low modern bench |
|
||||
| **street** bench_wood | street-furniture/woodbench | 11728→3999 | 1.34 × 0.59 | 0.85 | timber park bench |
|
||||
| **street** longbench | street-furniture/longbench | 1172→1172 | 5.86 × 1.08 | 0.85 | long feature bench (arcade/market) |
|
||||
| **street** park_bench | street-furniture/park_bench_01 | 9884→5000 | 1.92 × 1.62 | 0.85 | classic slatted park bench |
|
||||
| **street** streetlight | street-furniture/streetlight | 6592→4999 | 1.64 × 1.82 | 5.00 | ornate lamppost |
|
||||
| **street** food_cart | street-furniture/food_cart_bakso_01 | 30316→5000 | 3.00 × 2.07 | 2.00 | textured food cart (~3MB — heavy, 28 tex; optimize later) |
|
||||
|
||||
## Rejected during normalize (needs-manual-fix — NOT in manifest)
|
||||
|
||||
| source | issue (verified by render) | disposition |
|
||||
|---|---|---|
|
||||
| shop-fittings/cube_bookcase (`cube_shelf`) | node-scale distortion → 6.3 × 8.4 m footprint | dropped; `cube_shelf_wide` covers the need |
|
||||
| shop-fittings/garage_shelves_metal_v2 | imports lying flat (green pallet); baked node transform, up-fix didn't help | needs manual re-origin in Blender; `wire_shelf` covers shelving |
|
||||
| shop-fittings/table, saloon_table, table_02, desk_01, kitchen_table | degenerate/flat or node-distorted (`table` → 55×55 m; `desk` → 17×17 m) | rejected; `work_table`/`coffee_table` cover tables |
|
||||
| shop-fittings/radiogram_furniture_v2_l1 | Blender "Please select a file" on import (bad GLB) | needs re-export |
|
||||
| street-furniture/market_stall_01_CHECK | import error (`_CHECK` = sorter-flagged) | → MeshGod/primitive (see `MESHGOD_BATCH.md`) |
|
||||
|
||||
## QUARANTINE — licence unclear / branded (🔴 do NOT publish or ship)
|
||||
|
||||
The house rule is 🟢 web-ok only; anything branded or of unknown provenance is quarantined until
|
||||
cleared. These are **excluded from the manifest**:
|
||||
|
||||
| asset | reason |
|
||||
|---|---|
|
||||
| `furniture/armchair-vitra-eames`, `ines-chair-calligaris` | protected designer marques (Vitra/Eames®, Calligaris®) |
|
||||
| `shop-fittings/ikea-cupboard` | IKEA® product design (+259k tris) |
|
||||
| `props-scenes/mk2_1210_turntable_-_old` | Technics SL-1210® industrial design (trademark) |
|
||||
| `props-scenes/tuborg_fest_scene` | Tuborg® brand scene |
|
||||
| `furniture/uploads_files_*`, `props-scenes/uploads_files_*` (Couch William, Harland Office Chair, nv toufa, Backpack) | CGTrader/free3d bulk dumps, licence not recorded |
|
||||
| `furniture/sofa-c`, `hug-armchair`, `dragonchest`, `kitty-bed-01`, `bean_bag_chair` | provenance unrecorded — verify before use |
|
||||
|
||||
> **Blanket caveat (applies to the 16-asset publish set):** the *generic* fittings we normalized
|
||||
> (benches, shelves, counter, crate…) carry **no explicit licence/provenance record** — I cross-
|
||||
> checked all 16 source filenames and none is a recognizable trademark, and they were pre-sorted
|
||||
> into the house library by John for exactly this use, so they're treated as usable per the lane
|
||||
> brief. But "unclear ⇒ quarantine" is the house rule: **before `publish.py` pushes to the shared
|
||||
> CDN, record a one-line source/licence per asset**, and for any CC-BY source carry attribution.
|
||||
> If provenance genuinely can't be established for one, pull it from the publish set. This is a
|
||||
> reason the publish step is deliberately gated on John, not automated.
|
||||
|
||||
## Coverage vs the registry fittings needs
|
||||
|
||||
| shop type | fittings need | covered by | gap → |
|
||||
|---|---|---|---|
|
||||
| record | bins, crates, counter, listening corner | record_crate, counter, wire_shelf | **listening booth** → MeshGod |
|
||||
| opshop | clothes racks, bric-a-brac shelving, book wall | clothes_rack, wire_shelf, bookshelf, box_crate | — |
|
||||
| toy | cube shelves, display tables, **glass case** | cube_shelf_wide, coffee_table | **glass case** → MeshGod |
|
||||
| book | bookshelves, **spinner racks**, armchair | bookshelf, cube_shelf_wide | spinner rack, armchair → MeshGod/primitive |
|
||||
| video | aisle shelves, **returns slot**, poster wall | bookshelf, wire_shelf | returns slot, poster wall → primitive |
|
||||
| pawn | **glass cabinets**, wall hooks, barred window | counter | glass cabinet → MeshGod; pegboard = `tex-pegboard` skin |
|
||||
| milkbar | counter, **fridge**, magazine rack | counter | **fridge**, magazine rack → MeshGod/primitive |
|
||||
| dept | mixed sections, **escalator prop** | (all above) | escalator → primitive |
|
||||
| stall | trestle tables, crates | work_table, box_crate, record_crate | **market stall** → MeshGod/primitive |
|
||||
| street | bench/streetlight/bin/tree | bench×4, longbench, park_bench, streetlight, food_cart | bin (`council-bin` on depot), tree → billboard (Lane B) |
|
||||
|
||||
Gaps with no clean source are shortlisted in `MESHGOD_BATCH.md` (budget-gated) — everything has an
|
||||
in-engine primitive fallback, so none of these block the game.
|
||||
|
||||
## Depot cross-reference (`GET /api/list`, 138 assets)
|
||||
|
||||
- **90 named + 48 TRELLIS-hash.** Reusable house props already live (verify visually + licence
|
||||
before wiring): `street-bench.glb`, `council-bin.glb`, `milk-crate-stack.glb`, `cashbot.glb`.
|
||||
⚠️ `akai-mpc-live-iii-*` is a **trademarked product design** — same quarantine class as Technics/
|
||||
IKEA; do not treat as clean web-ok reuse without a licence check.
|
||||
- **No `procity_*` assets yet** — this lane is the first to publish that namespace.
|
||||
- Our 16 normalized GLBs are staged in `pipeline/_normalized/` and upload via `pipeline/publish.py`
|
||||
once `GOD3_PW` is provided (idempotent, reversible).
|
||||
|
||||
## Full library inventory (dispositions)
|
||||
|
||||
`shop-fittings/` (33): normalized 9 · rejected 8 (7 node-distorted/degenerate + radiogram bad-GLB) ·
|
||||
quarantine 1 (ikea) · unused/dup 15 (heavier or duplicate tables/shelves — `shelf_storage_rack_01`
|
||||
alone is 960k tris).
|
||||
`street-furniture/` (9): normalized 7 · error 1 (market_stall) · `building_shell_01` reserved for
|
||||
Lane B (38k-tri building shell — not a fitting).
|
||||
`furniture/` (17): mostly domestic (beds/sofas/chairs) — out of scope for shop fittings; 6 quarantined.
|
||||
`props-scenes/` (10): `dj_roomv01` + turntable = record-shop dressing (turntable quarantined: Technics);
|
||||
rest branded/scene dumps.
|
||||
|
||||
Raw stats for every **parsable `.glb`** (59 of the ~69 library entries; the rest are `.fbx`/`.obj`
|
||||
or subdir sources not counted): `pipeline/_audit_stats.json`. `glb_stat.py` reports **world-space**
|
||||
dims (node-transform-aware) and WebP/PNG/JPEG texture sizes. Normalizer output:
|
||||
`pipeline/_normalize_results.json`.
|
||||
60
pipeline/MESHGOD_BATCH.md
Normal file
@ -0,0 +1,60 @@
|
||||
# PROCITY bespoke hero props — MeshGod batch (Step 5)
|
||||
|
||||
> **Budget-gated. Do NOT run without John's go.** ≈ 9 solids × ~30¢ ≈ **$2.70**. Everything
|
||||
> here has an in-engine primitive fallback, so the game never blocks on it. The batch spec is
|
||||
> `pipeline/meshgod_batch.json` (djsim_home / thriftgod_hero pattern).
|
||||
|
||||
## What this fills
|
||||
|
||||
The audit coverage table (`AUDIT.md`) found the sorted library has **no usable source** for a
|
||||
handful of shop-defining props. These are them — the objects that make a shop-type read instantly:
|
||||
|
||||
| prop | shop type | why not from the library |
|
||||
|---|---|---|
|
||||
| listening-booth | record | nothing like it in the dump |
|
||||
| cash-register | all | none (till is universal) |
|
||||
| glass-case | toy / pawn / milkbar | audit found no glass display case |
|
||||
| drinks-fridge | milkbar | none |
|
||||
| magazine-rack | milkbar / pawn | none |
|
||||
| arcade-cabinet | arcade / dept | none |
|
||||
| milkshake-mixer | milkbar | none |
|
||||
| novelty-record | record (roof ornament) | the "ooh" landmark object |
|
||||
| bus-shelter | street | none (Lane B street dressing) |
|
||||
|
||||
## Run it (only after go-ahead)
|
||||
|
||||
```bash
|
||||
# 1. FIRST check for reuse — save money. cash-register & glass-case very likely already exist
|
||||
# from thriftgod_hero.json (its street-bench/council-bin/milk-crate-stack/cashbot are already
|
||||
# live on the depot):
|
||||
curl -s https://digalot.fyi/3god/api/list | grep -iE 'register|glass|display|fridge|arcade'
|
||||
|
||||
# 2. generate the ones not already available:
|
||||
cd ~/Documents/MESHGOD # (or meshgod)
|
||||
python -m meshgod.batch manifest ~/Documents/procity/pipeline/meshgod_batch.json --out assets3d/procity
|
||||
|
||||
# 3. finish + publish through the SAME house pipeline as the library assets:
|
||||
# meshgod.batch already runs finish_glb (metres/base-origin/decimate). Then normalize any
|
||||
# stragglers with pipeline/normalize.py and publish with pipeline/publish.py.
|
||||
```
|
||||
|
||||
## The MeshGod gotcha (why some gaps are NOT here)
|
||||
|
||||
Thin wires and flat discs reconstruct badly in image→3D. These stay **in-engine primitives**
|
||||
(listed under `_primitives_instead` in the JSON) — Lane B/C build them from boxes/cylinders:
|
||||
|
||||
- **spinner-rack** (thin wire) → extruded-cylinder primitive
|
||||
- **band-poster-easel** (thin A-frame) → box + canvas poster
|
||||
- **vhs-rewinder** (tiny box) → box primitive
|
||||
- **returns-slot** → a cut in the shopfront wall plane
|
||||
- **escalator-prop** → extruded ramp primitive
|
||||
- **pegboard-wall** → a plane skinned with `tex-pegboard.jpg` (already in `web/assets/gen/`)
|
||||
|
||||
The `novelty-record` is deliberately specced as a **thick chunky disc on a base** (not a flat
|
||||
disc) so it survives reconstruction.
|
||||
|
||||
## After generation
|
||||
|
||||
- Each solid → `procity_fit_<name>_01.glb` / `procity_street_<name>_01.glb`, thumbnail rendered,
|
||||
add to `web/assets/manifest.json` (re-run `build_manifest.py` once the results land), publish.
|
||||
- Re-run `validate_manifest.py --depot` to confirm they're live and the manifest stays green.
|
||||
91
pipeline/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# PROCITY pipeline (Lane E) — how to re-run everything
|
||||
|
||||
The asset pipeline: audit ultra's library → normalize the good bits to house GLB law → publish
|
||||
to the 3GOD depot → ship `web/assets/manifest.json` (the contract Lanes B/C/D read) → fill facade
|
||||
+ prop gaps. **Everything is optional at runtime** — the game runs asset-free; this lane makes it
|
||||
gorgeous, it doesn't make it work.
|
||||
|
||||
## Where things live
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| ultra (library + Blender) | `ssh johnking@100.91.239.7` — key auth, **no** coreutils/timeout, py3.9, Blender at `/Applications/Blender.app/Contents/MacOS/Blender` (not on PATH) |
|
||||
| model library | ultra `~/Documents/3D=models/{shop-fittings,street-furniture,furniture,props-scenes}` |
|
||||
| 3GOD depot | `https://digalot.fyi/3god` — `GET /a/<f>`, `GET /api/list`, `POST /api/upload?name=<f>` |
|
||||
| OpenRouter key (skins) | env `OPENROUTER_KEY` or `~/Documents/dealgod/lore/keys.env` (never committed) |
|
||||
| depot password (publish) | env `GOD3_PW` (never committed; ask John) — cookie = `hmac(GOD3_PW,"3god-ok")` |
|
||||
| Ollama (free LLM) | `http://100.89.131.57:11434` (qwen3) — for names/lore/cataloguing, unused so far |
|
||||
| MODELBEAST (free local gen) | this box, `~/Documents/MODELBEAST`. `flux_local` = FLUX.2-klein-4B text→image on MPS (**used for the facade skins** — ungated, weights cached). `sf3d`/`trellis_mac` = image→GLB, local but HF-gated (setup needed) |
|
||||
|
||||
## The steps (each is idempotent / resumable)
|
||||
|
||||
### 1. Audit — `AUDIT.md` *(free)*
|
||||
```bash
|
||||
scp pipeline/glb_stat.py johnking@100.91.239.7:/tmp/
|
||||
ssh johnking@100.91.239.7 'cd ~/Documents/3D=models && python3 /tmp/glb_stat.py --json shop-fittings street-furniture furniture props-scenes' > pipeline/_audit_stats.json
|
||||
```
|
||||
`glb_stat.py` is a dependency-free GLB parser (tris, bbox, texture sizes, Draco flag). Findings +
|
||||
licensing triage are written up in `AUDIT.md`.
|
||||
|
||||
### 2. Normalize — `normalize.py` → 16 GLBs + thumbs *(free, runs on ultra)*
|
||||
```bash
|
||||
scp pipeline/normalize.py pipeline/batch.json johnking@100.91.239.7:/tmp/
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
ssh johnking@100.91.239.7 "\$BL --background --python /tmp/normalize.py -- /tmp/batch.json /tmp/procity_norm"
|
||||
scp johnking@100.91.239.7:/tmp/procity_norm/_results.json pipeline/_normalize_results.json
|
||||
scp 'johnking@100.91.239.7:/tmp/procity_norm/thumbs/*.png' web/assets/thumbs/
|
||||
scp 'johnking@100.91.239.7:/tmp/procity_norm/*.glb' pipeline/_normalized/
|
||||
```
|
||||
Enforces the house GLB law (metres, +Y up, base origin, ≤5k tris, WebP ≤1024, no Draco), renders a
|
||||
256px thumbnail each. Extends `MESHGOD/scripts/finish_glb.py` (solidify/decimate/bright/base-origin)
|
||||
+ adds texture-webp, height-normalize, outlier cleanup, per-asset up/yaw. Edit `batch.json` to add
|
||||
assets; **always eyeball the thumbnails** — node-transform-distorted sources (see AUDIT rejects)
|
||||
look fine in-frame but have wrong footprints.
|
||||
|
||||
### 3. Manifest — `build_manifest.py` + `validate_manifest.py` *(free)*
|
||||
```bash
|
||||
python3 pipeline/build_manifest.py # → web/assets/manifest.json
|
||||
python3 pipeline/validate_manifest.py # Lane F gate: 0 errors = green
|
||||
python3 pipeline/validate_manifest.py --depot # stricter: require GLBs live on the depot
|
||||
```
|
||||
`build_manifest.py` derives skins from `web/assets/gen/` + fittings from `_normalize_results.json`;
|
||||
the curated facade→shop-type maps live at the top of that file (edit there when adding skins).
|
||||
|
||||
### 4. Publish — `publish.py` → 3GOD depot *(the one outward-facing step)*
|
||||
```bash
|
||||
python3 pipeline/publish.py --dry-run # free: list what would upload
|
||||
export GOD3_PW=... # ask John
|
||||
python3 pipeline/publish.py # POST each GLB + thumb to the depot (idempotent)
|
||||
```
|
||||
|
||||
### 5. Facade skin gaps — `gen_skins.py --local` *(DONE, free, on-device)*
|
||||
The 23-skin gap pack is **already generated + harvested + mapped**. To re-run or extend:
|
||||
```bash
|
||||
python3 pipeline/gen_skins.py --local --dry-run # free: list the pack, no GPU
|
||||
python3 pipeline/gen_skins.py --local # MODELBEAST flux2-klein-4b on MPS → .genraw/ (~5.7s/img)
|
||||
python3 pipeline/gen_skins.py --harvest # → web/assets/gen/facade-*.jpg, then re-run build_manifest.py
|
||||
```
|
||||
`--local` drives `~/Documents/MODELBEAST/server/operators/flux_local/run.py` directly (no server,
|
||||
no key, weights cached). It runs one GPU job at a time. Add skins by editing `PROMPTS` in
|
||||
`gen_skins.py` and their shop-type map in `build_manifest.py` (`FACADE_TYPES`). **Always eyeball
|
||||
`.genraw/` and delete rejects before `--harvest`.** Cloud fallback (drop `--local`): OpenRouter
|
||||
`google/gemini-3.1-flash-image`, ~$0.004/img, needs `OPENROUTER_KEY`. Manual flow: `SKIN_PROMPTS.md`.
|
||||
|
||||
### 6. Bespoke hero props — `MESHGOD_BATCH.md` + `meshgod_batch.json` *(≈$2.70, GATED)*
|
||||
Glass case, fridge, till, listening booth, etc. — the gaps no library asset fills. **Do not run
|
||||
without John's go**; check the DealGod cache / depot for reuse first (cash-register + glass-case
|
||||
likely already exist). Everything has a primitive fallback.
|
||||
|
||||
## Cost summary
|
||||
|
||||
| step | cost |
|
||||
|---|---|
|
||||
| audit, normalize, manifest, validate, publish | **free** (compute + bandwidth) |
|
||||
| facade skin gap-fill (23 imgs) | **free** — done on-device (MODELBEAST flux_local); cloud fallback ~9¢ |
|
||||
| MeshGod hero props (9 solids) | **~$2.70** — gated on John (local `sf3d`/`trellis_mac` is free but HF-gated) |
|
||||
|
||||
## Artifacts (not committed — regenerated from the steps)
|
||||
|
||||
`pipeline/_normalized/` (staging GLBs, live on depot), `pipeline/.genraw/` (raw skin gen).
|
||||
Committed: the scripts, `*.md`, `batch.json`, `meshgod_batch.json`, `_audit_stats.json`,
|
||||
`_normalize_results.json`, `web/assets/manifest.json`, `web/assets/thumbs/`, `docs/shots/laneE/`.
|
||||
93
pipeline/SKIN_PROMPTS.md
Normal file
@ -0,0 +1,93 @@
|
||||
# PROCITY facade skins — manual prompt pack (Step 4, route 2)
|
||||
|
||||
> Free route: paste these into the house image flow (Gemini / Flow) by hand. Route 1
|
||||
> (`pipeline/gen_skins.py`) generates the *identical* set automatically for ~9¢ if the
|
||||
> OpenRouter key is available. Either way, review, then save into `web/assets/gen/` under the
|
||||
> **exact filenames** below and re-run `python3 pipeline/build_manifest.py` to catalogue them.
|
||||
|
||||
## LOCK — prepend to every prompt (the style + POLY bible)
|
||||
|
||||
```
|
||||
1990s Australian op-shop aesthetic, slightly faded and worn, warm fluorescent lighting,
|
||||
kitsch but loving, no text unless specified, no watermarks.
|
||||
```
|
||||
|
||||
**Golden rules (why these match the existing 25):**
|
||||
- **Blank signboard** above the entrance — the game overlays the shop name (never bake text in).
|
||||
- **Flat front elevation, edge to edge** — it's a texture on a plane, not a hero shot. No angle,
|
||||
no sky margin, no ground margin (except the wide/house variants which include a sliver).
|
||||
- **Straight-on, overcast daylight.** Landscape framing (~16:9) for shopfronts.
|
||||
- Downsize to ≤1024px before saving (`sips -Z 1024 in.png --out out.jpg`).
|
||||
|
||||
---
|
||||
|
||||
## Shop-type facades (registry gaps) → `facade-<name>.jpg`
|
||||
|
||||
Template: *"Straight-on photograph of a small single-storey Australian suburban shop facade,
|
||||
**{desc}**, blank empty signboard above the entrance, glass shop window and a door, no text or
|
||||
lettering anywhere, overcast daylight, flat front elevation filling the entire frame edge to edge,
|
||||
nothing else visible."*
|
||||
|
||||
| save as | {desc} |
|
||||
|---|---|
|
||||
| `facade-toy-brights.jpg` | cheerful toy shop, primary-colour painted render, big low display window |
|
||||
| `facade-toy-lolly.jpg` | old-fashioned lolly and toy shop, pastel candy-stripe painted front |
|
||||
| `facade-book-barn.jpg` | a crammed secondhand book barn, tall dusty windows stacked with books, dark timber |
|
||||
| `facade-book-scholarly.jpg` | a genteel antiquarian bookshop, dark green paint, gold-line window, brass fittings |
|
||||
| `facade-video-neon.jpg` | a 1990s video rental store, glossy red and blue plastic signage band, poster light-boxes (all blank) |
|
||||
| `facade-video-suburban.jpg` | a suburban strip video shop, cream brick, glass double doors, returns chute |
|
||||
| `facade-milkbar-corner.jpg` | a classic corner milk bar, cream and green wall tiles, wide wrap-around window, ice-cream awning |
|
||||
| `facade-milkbar-fibro.jpg` | a little fibro milk bar with a fly-screen door, a bench out front, faded soft-drink colours |
|
||||
| `facade-milkbar-deco.jpg` | a streamline-deco milk bar, curved rendered corner, chrome trim, pastel mint paint |
|
||||
| `facade-arcade-entry.jpg` | a covered pedestrian shopping arcade entrance, tiled archway, terrazzo floor, patterned ceiling |
|
||||
| `facade-warehouse-roller.jpg` | an industrial discount warehouse shop, red brick with a big corrugated steel roller door, blank banner |
|
||||
| `facade-warehouse-tin.jpg` | a corrugated-tin clearance shed shopfront, roller door half up, concrete apron |
|
||||
| `facade-market-fruit.jpg` | an open fruit-and-veg shopfront, empty produce crates and astro-turf steps, striped awning |
|
||||
| `facade-market-edge.jpg` | a row of humble market-square edge shopfronts under one shared posted verandah, mixed paint colours |
|
||||
|
||||
## Wide department-store anchors → `facade-<name>.jpg` (Lane B maps onto a WIDE plane)
|
||||
|
||||
Template: *"Straight-on photograph of a **WIDE two-storey** Australian department-store anchor
|
||||
building facade, **{desc}**, a long blank signboard band across the top, rows of ground-floor
|
||||
display windows and a central entrance, no text or lettering anywhere, overcast daylight, flat
|
||||
front elevation filling the entire frame edge to edge, nothing else visible."*
|
||||
|
||||
| save as | {desc} |
|
||||
|---|---|
|
||||
| `facade-dept-anchor.jpg` | mid-century mustard-render and glass, cantilevered awning, grand central doors |
|
||||
| `facade-dept-emporium.jpg` | a federation-era brick emporium, arched upper windows, ornate parapet |
|
||||
|
||||
## Corner-lot second faces → `facade-<name>-side.jpg` (the `-side` convention)
|
||||
|
||||
> **Corner-lot decision:** in v1 Lane B reuses the *front* facade skin on the side face (documented
|
||||
> in `manifest.json` → `conventions.corner_lots`). These blank `-side` walls are the upgrade: a
|
||||
> windowless side elevation so corner buildings don't show a duplicated shopfront down the lane.
|
||||
|
||||
Template: *"Straight-on photograph of the **plain windowless side wall** of an Australian shop
|
||||
building, **{desc}**, no shopfront, no door, a faded blank rectangular ghost-sign area, no text or
|
||||
lettering anywhere, overcast daylight, flat elevation filling the entire frame edge to edge."*
|
||||
|
||||
| save as | {desc} |
|
||||
|---|---|
|
||||
| `facade-brick-side.jpg` | painted red brick, a downpipe, one small high window |
|
||||
| `facade-weatherboard-side.jpg` | cream weatherboard boards, a rain downpipe |
|
||||
| `facade-render-side.jpg` | weathered pastel painted render with hairline cracks |
|
||||
|
||||
## Residential fronts → `facade-<name>.jpg` (lots with `use:'house'`)
|
||||
|
||||
Template: *"Straight-on photograph of a modest Australian suburban house front, **{desc}**, front
|
||||
path and a bit of fence, no text anywhere, overcast daylight, flat front elevation filling the
|
||||
entire frame edge to edge, nothing else visible."*
|
||||
|
||||
| save as | {desc} |
|
||||
|---|---|
|
||||
| `facade-res-terrace.jpg` | a single-fronted Victorian terrace, iron-lace verandah, dark brick, tiny front garden |
|
||||
| `facade-res-weatherboard.jpg` | a modest weatherboard cottage, bull-nose verandah, picket fence |
|
||||
| `facade-res-fibro.jpg` | a 1950s cream fibro house, hip roof, small steel-framed windows |
|
||||
| `facade-res-brickveneer.jpg` | a 1970s brown brick-veneer house, aluminium sliding windows, carport edge |
|
||||
|
||||
---
|
||||
|
||||
**23 skins total.** After harvesting, `build_manifest.py` auto-catalogues any `facade-*.jpg`
|
||||
(new shop-type ones get their `types` from the map in `build_manifest.py` — add the new keys
|
||||
there so they attach to the right shop types; unmapped ones default to `["general"]`).
|
||||
1491
pipeline/_audit_stats.json
Normal file
294
pipeline/_normalize_results.json
Normal file
@ -0,0 +1,294 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"src": "shop-fittings/record_crate_plastic_01.glb",
|
||||
"file": "procity_fit_record_crate_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_record_crate_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_record_crate_01.png",
|
||||
"tri_before": 18320,
|
||||
"tri_after": 5000,
|
||||
"size_m": [
|
||||
0.464,
|
||||
0.41,
|
||||
0.635
|
||||
],
|
||||
"footprint": [
|
||||
0.46,
|
||||
0.63
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/shelf_metal_wire_01.glb",
|
||||
"file": "procity_fit_wire_shelf_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_wire_shelf_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_wire_shelf_01.png",
|
||||
"tri_before": 330,
|
||||
"tri_after": 330,
|
||||
"size_m": [
|
||||
1.167,
|
||||
1.8,
|
||||
0.437
|
||||
],
|
||||
"footprint": [
|
||||
1.17,
|
||||
0.44
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/clothes_rack_01.glb",
|
||||
"file": "procity_fit_clothes_rack_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_clothes_rack_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_clothes_rack_01.png",
|
||||
"tri_before": 3152,
|
||||
"tri_after": 3152,
|
||||
"size_m": [
|
||||
1.341,
|
||||
1.5,
|
||||
0.429
|
||||
],
|
||||
"footprint": [
|
||||
1.34,
|
||||
0.43
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/bookshelves.glb",
|
||||
"file": "procity_fit_bookshelf_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_bookshelf_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_bookshelf_01.png",
|
||||
"tri_before": 45128,
|
||||
"tri_after": 5000,
|
||||
"size_m": [
|
||||
0.197,
|
||||
2.0,
|
||||
1.019
|
||||
],
|
||||
"footprint": [
|
||||
0.2,
|
||||
1.02
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/cube_bookcase.glb",
|
||||
"file": "procity_fit_cube_shelf_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_cube_shelf_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_cube_shelf_01.png",
|
||||
"tri_before": 16960,
|
||||
"tri_after": 5000,
|
||||
"size_m": [
|
||||
6.328,
|
||||
1.4,
|
||||
8.42
|
||||
],
|
||||
"footprint": [
|
||||
6.33,
|
||||
8.42
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/shelf_cube_bookshelf_01.glb",
|
||||
"file": "procity_fit_cube_shelf_wide_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_cube_shelf_wide_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_cube_shelf_wide_01.png",
|
||||
"tri_before": 17734,
|
||||
"tri_after": 4999,
|
||||
"size_m": [
|
||||
0.751,
|
||||
1.1,
|
||||
1.236
|
||||
],
|
||||
"footprint": [
|
||||
0.75,
|
||||
1.24
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/balcao.glb",
|
||||
"file": "procity_fit_counter_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_counter_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_counter_01.png",
|
||||
"tri_before": 276,
|
||||
"tri_after": 276,
|
||||
"size_m": [
|
||||
1.062,
|
||||
1.1,
|
||||
4.018
|
||||
],
|
||||
"footprint": [
|
||||
1.06,
|
||||
4.02
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/coffeetable.glb",
|
||||
"file": "procity_fit_coffee_table_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_coffee_table_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_coffee_table_01.png",
|
||||
"tri_before": 495,
|
||||
"tri_after": 495,
|
||||
"size_m": [
|
||||
0.671,
|
||||
0.45,
|
||||
1.494
|
||||
],
|
||||
"footprint": [
|
||||
0.67,
|
||||
1.49
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/computertable.glb",
|
||||
"file": "procity_fit_work_table_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_work_table_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_work_table_01.png",
|
||||
"tri_before": 1556,
|
||||
"tri_after": 1556,
|
||||
"size_m": [
|
||||
0.44,
|
||||
0.75,
|
||||
1.044
|
||||
],
|
||||
"footprint": [
|
||||
0.44,
|
||||
1.04
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "shop-fittings/warehouse_box.glb",
|
||||
"file": "procity_fit_box_crate_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_fit_box_crate_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_fit_box_crate_01.png",
|
||||
"tri_before": 78,
|
||||
"tri_after": 78,
|
||||
"size_m": [
|
||||
1.352,
|
||||
0.6,
|
||||
1.324
|
||||
],
|
||||
"footprint": [
|
||||
1.35,
|
||||
1.32
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/bench.glb",
|
||||
"file": "procity_street_bench_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_bench_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_bench_01.png",
|
||||
"tri_before": 1942,
|
||||
"tri_after": 1942,
|
||||
"size_m": [
|
||||
0.437,
|
||||
0.85,
|
||||
1.214
|
||||
],
|
||||
"footprint": [
|
||||
0.44,
|
||||
1.21
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/eb_hipster_bench.glb",
|
||||
"file": "procity_street_bench_modern_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_bench_modern_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_bench_modern_01.png",
|
||||
"tri_before": 640,
|
||||
"tri_after": 640,
|
||||
"size_m": [
|
||||
1.682,
|
||||
0.45,
|
||||
0.467
|
||||
],
|
||||
"footprint": [
|
||||
1.68,
|
||||
0.47
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/woodbench.glb",
|
||||
"file": "procity_street_bench_wood_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_bench_wood_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_bench_wood_01.png",
|
||||
"tri_before": 11728,
|
||||
"tri_after": 3999,
|
||||
"size_m": [
|
||||
1.335,
|
||||
0.85,
|
||||
0.588
|
||||
],
|
||||
"footprint": [
|
||||
1.34,
|
||||
0.59
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/longbench.glb",
|
||||
"file": "procity_street_longbench_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_longbench_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_longbench_01.png",
|
||||
"tri_before": 1172,
|
||||
"tri_after": 1172,
|
||||
"size_m": [
|
||||
5.864,
|
||||
0.85,
|
||||
1.08
|
||||
],
|
||||
"footprint": [
|
||||
5.86,
|
||||
1.08
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/park_bench_01.glb",
|
||||
"file": "procity_street_park_bench_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_park_bench_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_park_bench_01.png",
|
||||
"tri_before": 9884,
|
||||
"tri_after": 5000,
|
||||
"size_m": [
|
||||
1.921,
|
||||
0.85,
|
||||
1.614
|
||||
],
|
||||
"footprint": [
|
||||
1.92,
|
||||
1.61
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/streetlight.glb",
|
||||
"file": "procity_street_streetlight_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_streetlight_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_streetlight_01.png",
|
||||
"tri_before": 6592,
|
||||
"tri_after": 4999,
|
||||
"size_m": [
|
||||
1.636,
|
||||
5.0,
|
||||
1.821
|
||||
],
|
||||
"footprint": [
|
||||
1.64,
|
||||
1.82
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "street-furniture/food_cart_bakso_01.glb",
|
||||
"file": "procity_street_food_cart_01.glb",
|
||||
"out": "/tmp/procity_norm/procity_street_food_cart_01.glb",
|
||||
"thumb": "/tmp/procity_norm/thumbs/procity_street_food_cart_01.png",
|
||||
"tri_before": 30220,
|
||||
"tri_after": 5000,
|
||||
"size_m": [
|
||||
3.003,
|
||||
2.0,
|
||||
2.075
|
||||
],
|
||||
"footprint": [
|
||||
3.0,
|
||||
2.07
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
20
pipeline/batch.json
Normal file
@ -0,0 +1,20 @@
|
||||
[
|
||||
{"src": "shop-fittings/record_crate_plastic_01.glb", "id": "record_crate", "category": "fit", "height_m": 0.41, "tris": 5000},
|
||||
{"src": "shop-fittings/shelf_metal_wire_01.glb", "id": "wire_shelf", "category": "fit", "height_m": 1.8, "tris": 5000},
|
||||
{"src": "shop-fittings/clothes_rack_01.glb", "id": "clothes_rack", "category": "fit", "height_m": 1.5, "tris": 5000},
|
||||
{"src": "shop-fittings/bookshelves.glb", "id": "bookshelf", "category": "fit", "height_m": 2.0, "tris": 5000},
|
||||
{"src": "shop-fittings/cube_bookcase.glb", "id": "cube_shelf", "category": "fit", "height_m": 1.4, "tris": 5000},
|
||||
{"src": "shop-fittings/shelf_cube_bookshelf_01.glb", "id": "cube_shelf_wide", "category": "fit", "height_m": 1.1, "tris": 5000},
|
||||
{"src": "shop-fittings/balcao.glb", "id": "counter", "category": "fit", "height_m": 1.1, "tris": 3000},
|
||||
{"src": "shop-fittings/coffeetable.glb", "id": "coffee_table", "category": "fit", "height_m": 0.45, "tris": 3000},
|
||||
{"src": "shop-fittings/computertable.glb", "id": "work_table", "category": "fit", "height_m": 0.75, "tris": 3000},
|
||||
{"src": "shop-fittings/warehouse_box.glb", "id": "box_crate", "category": "fit", "height_m": 0.6, "tris": 2000},
|
||||
|
||||
{"src": "street-furniture/bench.glb", "id": "bench", "category": "street", "height_m": 0.85, "tris": 3000},
|
||||
{"src": "street-furniture/eb_hipster_bench.glb", "id": "bench_modern", "category": "street", "height_m": 0.45, "tris": 3000},
|
||||
{"src": "street-furniture/woodbench.glb", "id": "bench_wood", "category": "street", "height_m": 0.85, "tris": 4000},
|
||||
{"src": "street-furniture/longbench.glb", "id": "longbench", "category": "street", "height_m": 0.85, "tris": 3000},
|
||||
{"src": "street-furniture/park_bench_01.glb", "id": "park_bench", "category": "street", "height_m": 0.85, "tris": 5000},
|
||||
{"src": "street-furniture/streetlight.glb", "id": "streetlight", "category": "street", "height_m": 5.0, "tris": 5000},
|
||||
{"src": "street-furniture/food_cart_bakso_01.glb", "id": "food_cart", "category": "street", "height_m": 2.0, "tris": 5000}
|
||||
]
|
||||
199
pipeline/build_manifest.py
Normal file
@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate web/assets/manifest.json — the single contract Lanes B/C/D read.
|
||||
|
||||
Mechanical parts (skin listing, fitting footprints) are derived from the filesystem +
|
||||
the normalizer's _normalize_results.json; the curated parts (which facade suits which
|
||||
shop type) are the maps below, seeded from CITY_SPEC's registry table. Re-run any time
|
||||
skins or normalized GLBs change: python3 pipeline/build_manifest.py
|
||||
|
||||
Everything here is OPTIONAL at runtime — the game runs asset-free (CITY_SPEC house law).
|
||||
The manifest just tells the other lanes what nicer assets exist and how big they are.
|
||||
"""
|
||||
import json, os, glob
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
GEN = os.path.join(ROOT, "web", "assets", "gen")
|
||||
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json")
|
||||
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
||||
DEPOT = "https://digalot.fyi/3god"
|
||||
|
||||
# ── curated: facade skin → shop types it suits ─────────────────────────────────────────
|
||||
# MIRRORS Lane A's web/js/core/registry.js SHOP_TYPES[*].facades (the authoritative render-time
|
||||
# pool) so there is ONE consistent mapping, plus additive-only coverage: cashtheists→pawn (a pawn
|
||||
# facade registry leaves unreferenced), corrugated→stall (registry's stall pool has only `market`;
|
||||
# this is stall's 2nd skin — flagged for Lane A), djsim-pub→general.
|
||||
FACADE_TYPES = {
|
||||
"timber-teal": ["record"], "arcade-tile": ["record"], "djsim-record": ["record"],
|
||||
"weatherboard": ["opshop"], "fibro-blue": ["opshop"], "djsim-opshop": ["opshop"],
|
||||
"stucco-pink": ["toy"], "deco-pastel": ["toy"], "boutique": ["toy"],
|
||||
"federation": ["book"], "sandstone": ["book"], "redbrick": ["book"],
|
||||
"stripmall": ["video"], "djsim-video": ["video"],
|
||||
"besser": ["pawn"], "grimy": ["pawn"], "djsim-pawn": ["pawn"],
|
||||
"corrugated": ["pawn", "stall"], "cashtheists": ["pawn"],
|
||||
"djsim-milkbar": ["milkbar"], "brickveneer": ["milkbar"],
|
||||
"terrazzo": ["dept"], "djsim-dept": ["dept"],
|
||||
"market": ["stall"],
|
||||
"djsim-pub": ["general"],
|
||||
# ── Step-4 gap-fill fronts, generated on-device (MODELBEAST flux2-klein-4b, free) ──────
|
||||
# docs/shots/laneE/facades_gen_local.png. Additive to registry.js: each fattens a thin
|
||||
# pool so no type leans on one skin (video 2→4, milkbar 2→5, dept 2→4, stall 1→3).
|
||||
"toy-brights": ["toy"], "toy-lolly": ["toy"],
|
||||
"book-barn": ["book"], "book-scholarly": ["book"],
|
||||
"video-neon": ["video"], "video-suburban": ["video"],
|
||||
"milkbar-corner": ["milkbar"], "milkbar-fibro": ["milkbar"], "milkbar-deco": ["milkbar"],
|
||||
"dept-anchor": ["dept"], "dept-emporium": ["dept"],
|
||||
"market-fruit": ["stall"], "market-edge": ["stall"],
|
||||
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
|
||||
"arcade-entry": ["general"], # arcade portal, not a shop — Lane B places by key
|
||||
# residential fronts for use:'house' lots (incl. the milkbar corner-shop's street)
|
||||
"res-terrace": ["house"], "res-weatherboard": ["house"],
|
||||
"res-fibro": ["house"], "res-brickveneer": ["house"],
|
||||
}
|
||||
# facades that already carry BAKED sign text (mostly the 90sDJsim Flux ones) — Lane B should NOT
|
||||
# overlay a shop name on these; the blank-signboard ones are the clean overlay targets. Verified
|
||||
# by eye against docs/shots/laneE/facades_style_anchor.png.
|
||||
BAKED_SIGN = {"djsim-milkbar", "djsim-video", "djsim-pub", "market", "grimy"}
|
||||
# ── curated: ground skin → where it belongs ─────────────────────────────────────────────
|
||||
GROUND_USE = {
|
||||
"asphalt1": "road", "asphalt2": "road", "djsim-road": "road",
|
||||
"footpath1": "footpath", "footpath2": "footpath", "djsim-footpath": "footpath",
|
||||
"grass": "yard", "brickpave": "plaza", "gravel": "verge", "reddust": "outback",
|
||||
}
|
||||
# ── curated: interior floor / surface split of the tex-* skins ───────────────────────────
|
||||
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",
|
||||
"boards-polished"]
|
||||
SURFACE_TEX = { # interior wall / counter surfaces → biased shop types (hint only)
|
||||
"wood-counter": ["record", "milkbar", "pawn"], "boutique-wall": ["toy", "opshop"],
|
||||
"pegboard": ["pawn", "video"], "brick": ["general"], "render": ["general"],
|
||||
"grimy-wall": ["pawn"], "velvet-curtain": ["toy", "record"],
|
||||
}
|
||||
AWNING_TEX = ["awning-red", "awning-green", "awning-blue"]
|
||||
|
||||
|
||||
def rel(p):
|
||||
return "gen/" + os.path.basename(p)
|
||||
|
||||
|
||||
def build():
|
||||
have = {os.path.splitext(os.path.basename(f))[0] for f in glob.glob(GEN + "/*.jpg")}
|
||||
|
||||
def f(cat, key):
|
||||
name = f"{cat}-{key}"
|
||||
return f"gen/{name}.jpg" if name in have else None
|
||||
|
||||
# ---- facades ----
|
||||
facade = {}
|
||||
for key, types in FACADE_TYPES.items():
|
||||
file = f("facade", key)
|
||||
if file:
|
||||
facade[key] = {"file": file, "types": types,
|
||||
"signboard": "baked" if key in BAKED_SIGN else "blank"}
|
||||
# any facade-*.jpg not in the curated map → catalogue it so nothing is orphaned.
|
||||
# `-side` skins are corner-lot SIDE walls (windowless): face:"side" + empty types so no
|
||||
# shop-front pool ever selects one — Lane B picks them by the facade-<x>-side.jpg name.
|
||||
for name in sorted(have):
|
||||
if not name.startswith("facade-") or name[len("facade-"):] in facade:
|
||||
continue
|
||||
key = name[len("facade-"):]
|
||||
is_side = key.endswith("-side")
|
||||
entry = {"file": "gen/" + name + ".jpg",
|
||||
"types": [] if is_side else ["general"],
|
||||
"signboard": "baked" if key in BAKED_SIGN else "blank"}
|
||||
if is_side:
|
||||
entry["face"] = "side"
|
||||
facade[key] = entry
|
||||
|
||||
# ---- skies (array; not type-specific) ----
|
||||
sky = [{"id": n[len("sky-"):], "file": "gen/" + n + ".jpg"}
|
||||
for n in sorted(have) if n.startswith("sky-")]
|
||||
|
||||
# ---- grounds (object keyed by id, with a use tag) ----
|
||||
ground = {}
|
||||
for name in sorted(have):
|
||||
if name.startswith("ground-"):
|
||||
key = name[len("ground-"):]
|
||||
ground[key] = {"file": "gen/" + name + ".jpg", "use": GROUND_USE.get(key, "road")}
|
||||
|
||||
# ---- interior wallpapers (array) ----
|
||||
wall = [{"id": n[len("wall-"):], "file": "gen/" + n + ".jpg"}
|
||||
for n in sorted(have) if n.startswith("wall-")]
|
||||
|
||||
# ---- interior floors + surfaces + street awnings (from tex-*) ----
|
||||
floor = [{"id": k, "file": f("tex", k)} for k in FLOOR_TEX if f("tex", k)]
|
||||
surface = [{"id": k, "file": f("tex", k), "types": t}
|
||||
for k, t in SURFACE_TEX.items() if f("tex", k)]
|
||||
awning = [{"id": k, "file": f("tex", k)} for k in AWNING_TEX if f("tex", k)]
|
||||
|
||||
skins = {
|
||||
"facade": facade,
|
||||
"sky": sky,
|
||||
"ground": ground,
|
||||
"wall": wall,
|
||||
"interior": {"floor": floor, "surface": surface},
|
||||
"awning": awning,
|
||||
}
|
||||
|
||||
# ---- fittings / furniture from the normalizer results ----
|
||||
fittings, furniture = {}, {}
|
||||
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
|
||||
DROP = {"procity_fit_cube_shelf_01.glb"}
|
||||
if os.path.exists(RESULTS):
|
||||
res = json.load(open(RESULTS))["results"]
|
||||
for r in res:
|
||||
if r["file"] in DROP:
|
||||
continue
|
||||
fid = r["file"].replace("procity_fit_", "").replace("procity_street_", "").replace("_01.glb", "")
|
||||
entry = {
|
||||
"file": r["file"],
|
||||
"footprint": r["footprint"], # [w, d] metres, on the ground
|
||||
"height": r["size_m"][1], # metres
|
||||
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
|
||||
}
|
||||
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
|
||||
|
||||
# registry.js semantic fitting id → our physical asset id (only where a GLB exists; the rest
|
||||
# stay primitives — see MESHGOD_BATCH.md). Lets a consumer that iterates registry SHOP_TYPES.fittings
|
||||
# resolve to a GLB. Lane C's glb.js already maps its own kinds straight to the physical ids below.
|
||||
fitting_aliases = {
|
||||
"record_bins": "record_crate", "crates": "box_crate", "counter": "counter",
|
||||
"clothes_racks": "clothes_rack", "bricabrac_shelving": "wire_shelf", "book_wall": "bookshelf",
|
||||
"cube_shelves": "cube_shelf_wide", "display_tables": "coffee_table",
|
||||
"bookshelves": "bookshelf", "vhs_shelving": "wire_shelf", "trestle_tables": "work_table",
|
||||
}
|
||||
fitting_aliases = {k: v for k, v in fitting_aliases.items() if v in fittings}
|
||||
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"depot": DEPOT,
|
||||
"conventions": {
|
||||
"glb_law": "metres, +Y up, origin at base (minY=0, centred X/Z), <=5k tris, WebP <=1024, no Draco",
|
||||
"facing": "symmetric props are orientation-agnostic; normalize.py applies no automatic "
|
||||
"facing-correction (per-asset yaw is available in batch.json) — Lane B/C rotate "
|
||||
"directional props (counter, food_cart) to taste when placing",
|
||||
"facade_blank_signboard": "facades tagged signboard:'blank' ship a blank sign panel — "
|
||||
"Lane B overlays the shop name there. signboard:'baked' facades "
|
||||
"(some djsim-* Flux skins) already have sign text: use as-is, don't overlay",
|
||||
"facade_face": "facade entries without a 'face' key are front elevations (shop or "
|
||||
"house fronts); face:'side' entries are windowless corner-lot side "
|
||||
"walls with empty types — never select one as a shop front",
|
||||
"corner_lots": "corner lots expose a side wall: dedicated facade-<x>-side.jpg skins "
|
||||
"now exist for brick/render/weatherboard (face:'side') — Lane B maps "
|
||||
"these to the exposed side face; other types reuse the front skin",
|
||||
"fallbacks": "every asset is optional — missing file => primitive/flat-colour, never a crash",
|
||||
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
|
||||
},
|
||||
"skins": skins,
|
||||
"fittings": fittings,
|
||||
"furniture": furniture,
|
||||
"fittingAliases": fitting_aliases,
|
||||
}
|
||||
with open(OUT, "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
print(f"wrote {OUT}")
|
||||
print(f" facades {len(facade)} skies {len(sky)} grounds {len(ground)} "
|
||||
f"wallpapers {len(wall)} floors {len(floor)} surfaces {len(surface)} awnings {len(awning)}")
|
||||
print(f" fittings {len(fittings)} furniture {len(furniture)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
215
pipeline/gen_skins.py
Normal file
@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY facade-skin gap-fill — ported from thriftgod/gen_assets.py (same engine, same bible).
|
||||
|
||||
Two backends, one prompt pack. The STYLE prefix and the blank-signboard FACADE template are
|
||||
lifted verbatim from thriftgod so new skins are indistinguishable from the 25 originals (Lane B
|
||||
overlays the shop name onto the blank sign).
|
||||
|
||||
--local (DEFAULT CHOICE) MODELBEAST flux_local → FLUX.2-klein-4B on the M3 Ultra's MPS GPU.
|
||||
FREE, on-device, Apache/ungated (weights already cached). ~7s/img, one job at a time.
|
||||
(cloud) OpenRouter `google/gemini-3.1-flash-image`, ~$0.004/img. Needs OPENROUTER_KEY.
|
||||
|
||||
python3 pipeline/gen_skins.py --local --dry-run # list the pack, no spend, no GPU
|
||||
python3 pipeline/gen_skins.py --local # generate all on-device → pipeline/.genraw/
|
||||
python3 pipeline/gen_skins.py --local --only res # just the residential family
|
||||
python3 pipeline/gen_skins.py --harvest # copy approved .genraw/* → web/assets/gen/facade-*.jpg
|
||||
|
||||
Everything here is a Step-4 GAP — the game already ships 25 facades and runs facade-free, so
|
||||
none of this blocks anyone. `--local` costs only electricity; cloud is the fallback if MODELBEAST
|
||||
is down. Review `.genraw/` by eye and delete rejects before `--harvest`.
|
||||
"""
|
||||
import base64, concurrent.futures, json, os, re, sys, time, urllib.request, shutil
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAW = os.path.join(ROOT, "pipeline", ".genraw")
|
||||
GEN = os.path.join(ROOT, "web", "assets", "gen")
|
||||
os.makedirs(RAW, exist_ok=True)
|
||||
MODEL = os.environ.get("TG_ART_MODEL", "google/gemini-3.1-flash-image")
|
||||
|
||||
KEY = os.environ.get("OPENROUTER_KEY")
|
||||
if not KEY: # house convention — key never committed
|
||||
kf = os.path.expanduser("~/Documents/dealgod/lore/keys.env")
|
||||
if os.path.exists(kf):
|
||||
for line in open(kf):
|
||||
if line.startswith("OPENROUTER_KEY="):
|
||||
KEY = line.strip().split("=", 1)[1]
|
||||
|
||||
# ── the style bible (verbatim from thriftgod gen_assets.py:20) ──────────────────────────
|
||||
STYLE = ("1990s Australian op-shop aesthetic, slightly faded and worn, warm fluorescent "
|
||||
"lighting, kitsch but loving, no text unless specified, no watermarks. ")
|
||||
# ── the blank-signboard shop-facade template (verbatim, gen_assets.py:69-73) ────────────
|
||||
FACADE = ("Straight-on photograph of a small single-storey Australian suburban shop facade, "
|
||||
"{v}, blank empty signboard above the entrance, glass shop window and a door, "
|
||||
"no text or lettering anywhere, overcast daylight, flat front elevation filling "
|
||||
"the entire frame edge to edge, nothing else visible")
|
||||
# ── wide anchor + windowless side-wall templates (same discipline, different geometry) ───
|
||||
WIDE = ("Straight-on photograph of a WIDE two-storey Australian department-store anchor "
|
||||
"building facade, {v}, a long blank signboard band across the top, rows of ground-floor "
|
||||
"display windows and a central entrance, no text or lettering anywhere, overcast daylight, "
|
||||
"flat front elevation filling the entire frame edge to edge, nothing else visible")
|
||||
SIDE = ("Straight-on photograph of the plain windowless SIDE wall of an Australian shop building, "
|
||||
"{v}, no shopfront, no door, a faded blank rectangular ghost-sign area, no text or lettering "
|
||||
"anywhere, overcast daylight, flat elevation filling the entire frame edge to edge")
|
||||
HOUSE = ("Straight-on photograph of a modest Australian suburban house front, {v}, front path and "
|
||||
"a bit of fence, no text anywhere, overcast daylight, flat front elevation filling the "
|
||||
"entire frame edge to edge, nothing else visible")
|
||||
|
||||
PROMPTS = {
|
||||
# ── shop-type facades that the registry is thin on ──────────────────────────────────
|
||||
**{f"facade-{k}": FACADE.format(v=v) for k, v in {
|
||||
"toy-brights": "cheerful toy shop, primary-colour painted render, big low display window",
|
||||
"toy-lolly": "old-fashioned lolly and toy shop, pastel candy-stripe painted front",
|
||||
"book-barn": "a crammed secondhand book barn, tall dusty windows stacked with books, dark timber",
|
||||
"book-scholarly": "a genteel antiquarian bookshop, dark green paint, gold-line window, brass fittings",
|
||||
"video-neon": "a 1990s video rental store, glossy red and blue plastic signage band, poster light-boxes (all blank)",
|
||||
"video-suburban": "a suburban strip video shop, cream brick, glass double doors, returns chute",
|
||||
"milkbar-corner": "a classic corner milk bar, cream and green wall tiles, wide wrap-around window, ice-cream awning",
|
||||
"milkbar-fibro": "a little fibro milk bar with a fly-screen door, a bench out front, faded soft-drink colours",
|
||||
"milkbar-deco": "a streamline-deco milk bar, curved rendered corner, chrome trim, pastel mint paint",
|
||||
"arcade-entry": "a covered pedestrian shopping arcade entrance, tiled archway, terrazzo floor, patterned ceiling",
|
||||
"warehouse-roller": "an industrial discount warehouse shop, red brick with a big corrugated steel roller door, blank banner",
|
||||
"warehouse-tin": "a corrugated-tin clearance shed shopfront, roller door half up, concrete apron",
|
||||
"market-fruit": "an open fruit-and-veg shopfront, empty produce crates and astro-turf steps, striped awning",
|
||||
"market-edge": "a row of humble market-square edge shopfronts under one shared posted verandah, mixed paint colours",
|
||||
}.items()},
|
||||
# ── the WIDE dept-store anchor (needs a wide UV plane in Lane B) ─────────────────────
|
||||
**{f"facade-{k}": WIDE.format(v=v) for k, v in {
|
||||
"dept-anchor": "mid-century mustard-render and glass, cantilevered awning, grand central doors",
|
||||
"dept-emporium": "a federation-era brick emporium, arched upper windows, ornate parapet",
|
||||
}.items()},
|
||||
# ── corner-lot second faces: the `-side` convention (see manifest conventions) ──────
|
||||
**{f"facade-{k}-side": SIDE.format(v=v) for k, v in {
|
||||
"brick": "painted red brick, a downpipe, one small high window",
|
||||
"weatherboard": "cream weatherboard boards, a rain downpipe",
|
||||
"render": "weathered pastel painted render with hairline cracks",
|
||||
}.items()},
|
||||
# ── residential terrace/cottage fronts (lots with use:'house') ──────────────────────
|
||||
**{f"facade-{k}": HOUSE.format(v=v) for k, v in {
|
||||
"res-terrace": "a single-fronted Victorian terrace, iron-lace verandah, dark brick, tiny front garden",
|
||||
"res-weatherboard": "a modest weatherboard cottage, bull-nose verandah, picket fence",
|
||||
"res-fibro": "a 1950s cream fibro house, hip roof, small steel-framed windows",
|
||||
"res-brickveneer": "a 1970s brown brick-veneer house, aluminium sliding windows, carport edge",
|
||||
}.items()},
|
||||
}
|
||||
|
||||
|
||||
def gen(slug, prompt):
|
||||
body = json.dumps({"model": MODEL, "modalities": ["image", "text"],
|
||||
"messages": [{"role": "user", "content": STYLE + prompt}]}).encode()
|
||||
req = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body,
|
||||
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
d = json.load(r)
|
||||
msg = d["choices"][0]["message"]
|
||||
imgs = msg.get("images") or []
|
||||
url = imgs[0]["image_url"]["url"] if imgs else ""
|
||||
m = re.match(r"data:image/(\w+);base64,(.+)", url, re.S)
|
||||
if not m:
|
||||
raise RuntimeError(f"no image in response: {str(msg)[:120]}")
|
||||
fn = os.path.join(RAW, f"{slug}.{m.group(1)}")
|
||||
with open(fn, "wb") as f:
|
||||
f.write(base64.b64decode(m.group(2)))
|
||||
return fn
|
||||
|
||||
|
||||
# ── LOCAL backend: MODELBEAST flux_local (mflux / FLUX.2-klein-4B, MPS) — FREE, ~7s/img, ungated ──
|
||||
import subprocess, shutil as _sh
|
||||
MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST"))
|
||||
FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python")
|
||||
FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py")
|
||||
|
||||
|
||||
def local_available():
|
||||
return os.path.exists(FLUX_PY) and os.path.exists(FLUX_RUN)
|
||||
|
||||
|
||||
def gen_local(slug, prompt, w=1024, h=576, steps=4, seed=7):
|
||||
outdir = os.path.join(RAW, "_job_" + slug)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
params = json.dumps({"prompt": STYLE + prompt, "model": "flux2-klein-4b",
|
||||
"steps": steps, "width": w, "height": h, "seed": seed,
|
||||
"quantize": "8", "guidance": 3.5})
|
||||
r = subprocess.run([FLUX_PY, FLUX_RUN, "--outdir", outdir, "--params", params],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
pngs = [p for p in os.listdir(outdir) if p.endswith(".png")]
|
||||
if not pngs:
|
||||
raise RuntimeError((r.stderr or r.stdout or "no png")[-160:])
|
||||
fn = os.path.join(RAW, f"{slug}.png")
|
||||
_sh.move(os.path.join(outdir, pngs[0]), fn)
|
||||
_sh.rmtree(outdir, ignore_errors=True)
|
||||
return fn
|
||||
|
||||
|
||||
def have(slug):
|
||||
return any(os.path.exists(os.path.join(RAW, f"{slug}.{e}")) for e in ("png", "jpeg", "jpg", "webp"))
|
||||
|
||||
|
||||
LOCAL = False # set by --local: use MODELBEAST flux_local instead of paid OpenRouter
|
||||
|
||||
|
||||
def gen_one(item):
|
||||
slug, prompt = item
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
fn = gen_local(slug, prompt) if LOCAL else gen(slug, prompt)
|
||||
return slug, os.path.getsize(fn) // 1024, None
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
return slug, 0, str(e)[:90]
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def harvest():
|
||||
"""Copy reviewed .genraw/*.{png,jpeg} into web/assets/gen/ as *.jpg. Manual review first —
|
||||
only files you keep in .genraw/ get harvested. Uses sips if available for real JPEG."""
|
||||
n = 0
|
||||
for f in sorted(os.listdir(RAW)):
|
||||
slug, ext = os.path.splitext(f)
|
||||
if ext.lower() not in (".png", ".jpeg", ".jpg", ".webp"):
|
||||
continue
|
||||
dst = os.path.join(GEN, f"{slug}.jpg")
|
||||
src = os.path.join(RAW, f)
|
||||
if ext.lower() in (".jpg", ".jpeg"):
|
||||
shutil.copyfile(src, dst)
|
||||
else:
|
||||
if os.system(f"sips -s format jpeg {src!r} --out {dst!r} >/dev/null 2>&1") != 0:
|
||||
shutil.copyfile(src, dst) # fallback: copy as-is (rename)
|
||||
n += 1
|
||||
print(f" harvested {slug}.jpg")
|
||||
print(f"harvested {n} → {GEN} (re-run build_manifest.py to catalogue them)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--harvest" in sys.argv:
|
||||
harvest(); sys.exit(0)
|
||||
LOCAL = "--local" in sys.argv # MODELBEAST flux_local: on-device, free, ungated
|
||||
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
|
||||
# local = one GPU job at a time (MPS won't share cleanly); cloud fans out
|
||||
default_workers = 1 if LOCAL else 4
|
||||
workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else default_workers
|
||||
todo = {s: p for s, p in PROMPTS.items()
|
||||
if (not only or only in s) and not have(s)}
|
||||
backend = "MODELBEAST flux2-klein-4b, local·free" if LOCAL else MODEL
|
||||
cost = 0.0 if LOCAL else len(todo) * 0.004
|
||||
print(f"{len(todo)} skins to generate ({backend}) ≈ ${cost:.2f}" + (f" filter:*{only}*" if only else ""))
|
||||
if "--dry-run" in sys.argv:
|
||||
for s in sorted(todo):
|
||||
print(f" {s}")
|
||||
sys.exit(0)
|
||||
if LOCAL:
|
||||
if not local_available():
|
||||
print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN}).\n"
|
||||
"Set MB_HOME or check ~/Documents/MODELBEAST/server/operators/flux_local/.")
|
||||
sys.exit(2)
|
||||
elif not KEY:
|
||||
print("ERROR: OPENROUTER_KEY not set and no ~/Documents/dealgod/lore/keys.env.\n"
|
||||
"Use --local (MODELBEAST, free) or route 2: pipeline/SKIN_PROMPTS.md.")
|
||||
sys.exit(2)
|
||||
fails = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
for i, (slug, kb, err) in enumerate(ex.map(gen_one, todo.items()), 1):
|
||||
if err:
|
||||
fails += 1; print(f"[{i}/{len(todo)}] {slug} FAILED: {err}")
|
||||
else:
|
||||
print(f"[{i}/{len(todo)}] {slug} ({kb}KB)")
|
||||
print(f"done, {fails} failures. Review .genraw/, delete rejects, then --harvest.")
|
||||
225
pipeline/glb_stat.py
Normal file
@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free GLB inspector for the PROCITY asset audit.
|
||||
|
||||
Parses .glb binary containers (no Blender, no pygltflib) and reports, per file:
|
||||
tri count, bounding box in metres (scale sanity), node/mesh/material/image counts,
|
||||
and embedded texture pixel dimensions. Also flags likely house-GLB-law violations.
|
||||
|
||||
Usage:
|
||||
python3 glb_stat.py FILE.glb [FILE2.glb ...] # human table
|
||||
python3 glb_stat.py --json DIR # JSON, recurse *.glb in DIR
|
||||
python3 glb_stat.py --json FILE.glb # JSON for one file
|
||||
|
||||
House GLB law (CITY_SPEC / LANE_E): metres, +Y up, origin at base, facing -Z,
|
||||
props <=5k tris, textures <=1024, no Draco.
|
||||
"""
|
||||
import json, struct, sys, os, glob
|
||||
|
||||
COMPONENT_SIZE = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4}
|
||||
TYPE_COUNT = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4,
|
||||
"MAT2": 4, "MAT3": 9, "MAT4": 16}
|
||||
|
||||
|
||||
def _read_glb(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if data[:4] != b"glTF":
|
||||
# maybe a .gltf JSON, or non-glb; try JSON parse
|
||||
try:
|
||||
return json.loads(data.decode("utf-8")), b""
|
||||
except Exception:
|
||||
raise ValueError("not a GLB/glTF")
|
||||
ver, length = struct.unpack("<II", data[4:12])
|
||||
off, gltf, bin_chunk = 12, None, b""
|
||||
while off < len(data):
|
||||
clen, ctype = struct.unpack("<II", data[off:off + 8])
|
||||
chunk = data[off + 8: off + 8 + clen]
|
||||
if ctype == 0x4E4F534A: # 'JSON'
|
||||
gltf = json.loads(chunk.decode("utf-8"))
|
||||
elif ctype == 0x004E4942: # 'BIN\0'
|
||||
bin_chunk = chunk
|
||||
off += 8 + clen
|
||||
return gltf, bin_chunk
|
||||
|
||||
|
||||
def _prim_tris(gltf, prim):
|
||||
"""Triangle count for one primitive (mode 4 / default = TRIANGLES)."""
|
||||
mode = prim.get("mode", 4)
|
||||
accessors = gltf.get("accessors", [])
|
||||
if "indices" in prim:
|
||||
count = accessors[prim["indices"]]["count"]
|
||||
else:
|
||||
pos = prim.get("attributes", {}).get("POSITION")
|
||||
if pos is None:
|
||||
return 0
|
||||
count = accessors[pos]["count"]
|
||||
if mode == 4:
|
||||
return count // 3
|
||||
if mode in (5, 6): # triangle strip / fan
|
||||
return max(0, count - 2)
|
||||
return 0 # points/lines contribute no tris
|
||||
|
||||
|
||||
def _mat_mul(A, B):
|
||||
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
|
||||
|
||||
|
||||
def _node_matrix(n):
|
||||
if "matrix" in n: # column-major → row-major
|
||||
m = n["matrix"]
|
||||
return [[m[c * 4 + r] for c in range(4)] for r in range(4)]
|
||||
t = n.get("translation", [0, 0, 0]); q = n.get("rotation", [0, 0, 0, 1]); s = n.get("scale", [1, 1, 1])
|
||||
x, y, z, w = q
|
||||
R = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), 0],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), 0],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y), 0],
|
||||
[0, 0, 0, 1]]
|
||||
S = [[s[0], 0, 0, 0], [0, s[1], 0, 0], [0, 0, s[2], 0], [0, 0, 0, 1]]
|
||||
T = [[1, 0, 0, t[0]], [0, 1, 0, t[1]], [0, 0, 1, t[2]], [0, 0, 0, 1]]
|
||||
return _mat_mul(_mat_mul(T, R), S)
|
||||
|
||||
|
||||
def _xform(M, p):
|
||||
return [sum(M[i][j] * ([p[0], p[1], p[2], 1][j]) for j in range(4)) for i in range(3)]
|
||||
|
||||
|
||||
def stat(path):
|
||||
gltf, _ = _read_glb(path)
|
||||
accessors = gltf.get("accessors", [])
|
||||
meshes = gltf.get("meshes", [])
|
||||
nodes = gltf.get("nodes", [])
|
||||
tris = 0
|
||||
for m in meshes: # tri count is transform-independent
|
||||
for p in m.get("primitives", []):
|
||||
tris += _prim_tris(gltf, p)
|
||||
# WORLD-space bbox: walk the scene graph so node TRS (unit scale / pivot offset) is respected —
|
||||
# local accessor min/max alone lies for multi-node assets (missed a 1km off-origin bug once).
|
||||
mn = [float("inf")] * 3
|
||||
mx = [float("-inf")] * 3
|
||||
scenes = gltf.get("scenes") or [{"nodes": list(range(len(nodes)))}]
|
||||
roots = scenes[gltf.get("scene", 0)].get("nodes", [])
|
||||
|
||||
def walk(ni, M):
|
||||
n = nodes[ni]
|
||||
W = _mat_mul(M, _node_matrix(n))
|
||||
if "mesh" in n:
|
||||
for p in meshes[n["mesh"]].get("primitives", []):
|
||||
pos = p.get("attributes", {}).get("POSITION")
|
||||
if pos is None:
|
||||
continue
|
||||
a = accessors[pos]
|
||||
if "min" not in a or "max" not in a:
|
||||
continue
|
||||
lo, hi = a["min"], a["max"]
|
||||
for cx in (lo[0], hi[0]):
|
||||
for cy in (lo[1], hi[1]):
|
||||
for cz in (lo[2], hi[2]):
|
||||
w = _xform(W, [cx, cy, cz])
|
||||
for k in range(3):
|
||||
mn[k] = min(mn[k], w[k]); mx[k] = max(mx[k], w[k])
|
||||
for c in n.get("children", []):
|
||||
walk(c, W)
|
||||
|
||||
I = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
|
||||
for r in roots:
|
||||
walk(r, I)
|
||||
dims = [round(mx[i] - mn[i], 3) if mx[i] > mn[i] else 0.0 for i in range(3)]
|
||||
# texture pixel sizes from embedded images (PNG/JPEG header sniff)
|
||||
tex_sizes = []
|
||||
images = gltf.get("images", [])
|
||||
bufviews = gltf.get("bufferViews", [])
|
||||
_, binc = _read_glb(path)
|
||||
for im in images:
|
||||
wh = None
|
||||
if "bufferView" in im and binc:
|
||||
bv = bufviews[im["bufferView"]]
|
||||
start = bv.get("byteOffset", 0)
|
||||
blob = binc[start:start + bv.get("byteLength", 0)]
|
||||
wh = _img_size(blob)
|
||||
if wh:
|
||||
tex_sizes.append(wh)
|
||||
exts = gltf.get("extensionsUsed", []) or []
|
||||
draco = any("draco" in e.lower() for e in exts)
|
||||
return {
|
||||
"file": os.path.basename(path),
|
||||
"tris": tris,
|
||||
"dims_m": dims, # x,y,z extent in file units (hopefully metres)
|
||||
"nodes": len(gltf.get("nodes", [])),
|
||||
"meshes": len(meshes),
|
||||
"materials": len(gltf.get("materials", [])),
|
||||
"images": len(images),
|
||||
"tex_sizes": tex_sizes,
|
||||
"max_tex": max((max(w, h) for w, h in tex_sizes), default=0),
|
||||
"draco": draco,
|
||||
"exts": exts,
|
||||
}
|
||||
|
||||
|
||||
def _img_size(blob):
|
||||
if blob[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
w, h = struct.unpack(">II", blob[16:24])
|
||||
return (w, h)
|
||||
if blob[:2] == b"\xff\xd8": # JPEG: scan SOF markers
|
||||
i = 2
|
||||
while i < len(blob) - 9:
|
||||
if blob[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = blob[i + 1]
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
||||
h, w = struct.unpack(">HH", blob[i + 5:i + 9])
|
||||
return (w, h)
|
||||
seglen = struct.unpack(">H", blob[i + 2:i + 4])[0]
|
||||
i += 2 + seglen
|
||||
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": # WebP (normalize.py exports these)
|
||||
fmt = blob[12:16]
|
||||
if fmt == b"VP8 ": # lossy: 16-bit dims at offset 26 (14-bit each)
|
||||
w = struct.unpack("<H", blob[26:28])[0] & 0x3FFF
|
||||
h = struct.unpack("<H", blob[28:30])[0] & 0x3FFF
|
||||
return (w, h)
|
||||
if fmt == b"VP8L": # lossless: 14-bit dims packed after the 0x2F sig
|
||||
b0, b1, b2, b3 = blob[21], blob[22], blob[23], blob[24]
|
||||
w = ((b1 & 0x3F) << 8 | b0) + 1
|
||||
h = ((b3 & 0x0F) << 10 | b2 << 2 | (b1 & 0xC0) >> 6) + 1
|
||||
return (w, h)
|
||||
if fmt == b"VP8X": # extended: 24-bit dims at offset 24
|
||||
w = (blob[24] | blob[25] << 8 | blob[26] << 16) + 1
|
||||
h = (blob[27] | blob[28] << 8 | blob[29] << 16) + 1
|
||||
return (w, h)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
as_json = "--json" in args
|
||||
args = [a for a in args if a != "--json"]
|
||||
files = []
|
||||
for a in args:
|
||||
if os.path.isdir(a):
|
||||
files += sorted(glob.glob(os.path.join(a, "**", "*.glb"), recursive=True))
|
||||
else:
|
||||
files.append(a)
|
||||
rows = []
|
||||
for f in files:
|
||||
try:
|
||||
rows.append(stat(f))
|
||||
except Exception as e:
|
||||
rows.append({"file": os.path.basename(f), "error": str(e)})
|
||||
if as_json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return
|
||||
hdr = f"{'file':38} {'tris':>7} {'dims (m) x,y,z':>22} {'mtl':>3} {'img':>3} {'maxtex':>6} draco"
|
||||
print(hdr)
|
||||
print("-" * len(hdr))
|
||||
for r in rows:
|
||||
if "error" in r:
|
||||
print(f"{r['file']:38} ERROR: {r['error']}")
|
||||
continue
|
||||
d = r["dims_m"]
|
||||
dstr = f"{d[0]:.2f},{d[1]:.2f},{d[2]:.2f}"
|
||||
print(f"{r['file']:38} {r['tris']:>7} {dstr:>22} {r['materials']:>3} "
|
||||
f"{r['images']:>3} {r['max_tex']:>6} {'YES' if r['draco'] else '-'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
pipeline/meshgod_batch.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"_comment": "PROCITY hero props — the gaps the sorted library CAN'T fill (see AUDIT.md coverage table). Text->3D via MeshGod. height_m = real-world height (1 unit = 1m). Run ONLY with John's go (≈30c/solid ≈ $2.70): python -m meshgod.batch manifest pipeline/meshgod_batch.json --out assets3d/procity . BEFORE generating, check the DealGod canon-3D cache / 3GOD /api/list for reuse (cash-register + glass-case likely already exist from thriftgod_hero.json). MeshGod gotcha: thin wires / flat discs reconstruct badly — those stay in-engine primitives and are NOT in this list.",
|
||||
"assets": [
|
||||
{"name": "listening-booth", "prompt": "a record shop listening station booth, angled timber panel with a shelf and hanging headphones, single stool", "height_m": 1.6, "tris": 6000, "for": "record"},
|
||||
{"name": "cash-register", "prompt": "an ornate antique brass cash register with round number keys and a bell", "height_m": 0.35, "tris": 6000, "for": "all", "reuse": "thriftgod_hero:vintage-cash-register — check depot first"},
|
||||
{"name": "glass-case", "prompt": "a retail glass display case counter with a wooden base and glass top shelves", "height_m": 1.0, "tris": 6000, "for": "toy/pawn/milkbar", "reuse": "thriftgod_hero:glass-display-case — check depot first"},
|
||||
{"name": "drinks-fridge", "prompt": "a round-shouldered vintage upright drinks fridge with a glass door and faded stickers", "height_m": 1.6, "tris": 5000, "for": "milkbar"},
|
||||
{"name": "magazine-rack", "prompt": "a slanted timber and wire magazine display rack with tiered shelves", "height_m": 1.2, "tris": 4000, "for": "milkbar/pawn"},
|
||||
{"name": "arcade-cabinet", "prompt": "a 1990s upright arcade machine cabinet shell, boxy body, sloped control panel, dark screen bezel", "height_m": 1.7, "tris": 6000, "for": "arcade/dept"},
|
||||
{"name": "milkshake-mixer", "prompt": "a chrome twin-spindle milkshake mixer machine on a diner counter, solid body", "height_m": 0.5, "tris": 4000, "for": "milkbar"},
|
||||
{"name": "novelty-record", "prompt": "an oversized thick vinyl record standing on edge as a roadside novelty ornament, solid chunky disc on a base", "height_m": 2.0, "tris": 4000, "for": "record roof-ornament"},
|
||||
{"name": "bus-shelter", "prompt": "an Australian suburban bus stop shelter, steel frame with scratched perspex panels and a bench seat", "height_m": 2.4, "tris": 8000, "for": "street"}
|
||||
],
|
||||
"_primitives_instead": {
|
||||
"_comment": "MeshGod gotcha assets — thin/flat geometry reconstructs badly. Lane B/C build these as in-engine primitives, NOT via MeshGod:",
|
||||
"spinner-rack": "thin wire book/card spinner — extruded-cylinder primitive",
|
||||
"band-poster-easel": "thin A-frame sidewalk sign — box primitive + canvas poster",
|
||||
"vhs-rewinder": "tiny box on the counter — box primitive",
|
||||
"returns-slot": "wall slot for the video returns — cut in the wall plane",
|
||||
"escalator-prop": "dept-store escalator — extruded ramp primitive",
|
||||
"pegboard-wall": "pawn/video wall — plane with tex-pegboard.jpg skin (already in gen/)"
|
||||
}
|
||||
}
|
||||
294
pipeline/normalize.py
Normal file
@ -0,0 +1,294 @@
|
||||
"""PROCITY GLB normalizer — enforce the house GLB law on library assets.
|
||||
|
||||
Runs headless on ultra (Blender at /Applications/Blender.app/Contents/MacOS/Blender):
|
||||
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python normalize.py -- BATCH.json OUT_DIR [THUMB_DIR]
|
||||
|
||||
Processes every entry in BATCH.json in ONE Blender session (clean scene between
|
||||
assets — no per-asset relaunch). For each asset it:
|
||||
|
||||
* imports (Blender applies the glTF Y-up convention → correct world orientation)
|
||||
* joins to a single mesh, applies transforms
|
||||
* optional up-axis correction + yaw so the asset stands up and faces -Z
|
||||
* base-origin: centre in X/Y, sit on the floor (min Z = 0) [house law]
|
||||
* height-normalises to a real-world metre height [house law]
|
||||
* decimates to a triangle budget (props <=5k) [house law]
|
||||
* optional self-illuminate (TRELLIS/dark-bake assets) (finish_glb.py:88)
|
||||
* downsizes every texture to <=1024px and exports them as WebP [house law]
|
||||
* exports GLB, +Y up, embedded textures, NO Draco [house law]
|
||||
* renders a 256px hero thumbnail (workbench, textured) [step 2]
|
||||
|
||||
Output names: procity_<category>_<id>_01.glb (e.g. procity_fit_record_crate_01.glb)
|
||||
|
||||
The heavy finish logic (solidify/decimate/bright/base-origin) is lifted verbatim
|
||||
from MESHGOD/scripts/finish_glb.py — this is the same law, extended for the
|
||||
PROCITY batch + texture-webp + thumbnail, per the lane rule "extend, don't fork".
|
||||
|
||||
BATCH.json schema (list of):
|
||||
{ "src": "shop-fittings/balcao.glb", # relative to --root (default ~/Documents/3D=models)
|
||||
"id": "counter", "category": "fit", # → procity_fit_counter_01.glb
|
||||
"height_m": 1.1, # target height (0 = keep source scale)
|
||||
"tris": 3000, # triangle budget
|
||||
"up": "none", # none|x+90|x-90|y+90|y-90 (stand a lying asset up)
|
||||
"yaw": 0, # degrees about up-axis, so front faces -Z
|
||||
"solidify": false, # cap open bottoms (TRELLIS scans only)
|
||||
"bright": false } # self-illuminate dark-baked albedo
|
||||
"""
|
||||
import bpy, sys, os, math, json
|
||||
from mathutils import Vector
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
BATCH = ARGV[0]
|
||||
OUT_DIR = ARGV[1] if len(ARGV) > 1 else "/tmp/procity_norm"
|
||||
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
|
||||
ROOT = os.environ.get("PROCITY_MODELS_ROOT", os.path.expanduser("~/Documents/3D=models"))
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
os.makedirs(THUMB_DIR, exist_ok=True)
|
||||
|
||||
UP_ROT = { # extra rotation to stand a mis-authored asset upright
|
||||
"none": (0, 0, 0), "x+90": (math.radians(90), 0, 0), "x-90": (math.radians(-90), 0, 0),
|
||||
"y+90": (0, math.radians(90), 0), "y-90": (0, math.radians(-90), 0),
|
||||
"z+90": (0, 0, math.radians(90)), "z-90": (0, 0, math.radians(-90)),
|
||||
}
|
||||
|
||||
|
||||
def wipe():
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for blk in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
|
||||
for d in list(blk):
|
||||
if d.users == 0:
|
||||
blk.remove(d)
|
||||
|
||||
|
||||
def bounds(obj):
|
||||
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
mn = Vector((min(v.x for v in bb), min(v.y for v in bb), min(v.z for v in bb)))
|
||||
mx = Vector((max(v.x for v in bb), max(v.y for v in bb), max(v.z for v in bb)))
|
||||
return mn, mx
|
||||
|
||||
|
||||
def import_join(src):
|
||||
ext = os.path.splitext(src)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=src)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=src)
|
||||
elif ext == ".obj":
|
||||
bpy.ops.wm.obj_import(filepath=src)
|
||||
else:
|
||||
raise ValueError("unsupported: " + ext)
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
if not meshes:
|
||||
raise ValueError("no meshes")
|
||||
# BAKE ancestor transforms into each mesh, then drop non-mesh nodes. glТF imports often park
|
||||
# the mesh under a parent empty carrying unit-conversion scale / pivot offset — joining +
|
||||
# transform_apply alone leaves that parent node in the export (asset ships off-origin & scaled,
|
||||
# violating "origin at base"). parent_clear KEEP_TRANSFORM folds matrix_world into each basis.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type != 'MESH': # empties, armatures, cameras, lights
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
obj = bpy.context.view_layer.objects.active
|
||||
obj.modifiers.clear() # drop armature/etc modifiers (static props)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
def clean_mesh(obj):
|
||||
"""Drop stray/loose geometry that inflates the bbox (dumped assets are full of it):
|
||||
weld doubles, delete loose verts/edges, then clip statistical outlier verts
|
||||
(|coord-median| > 6*MAD on any axis) — the stray points that make a table read 55m."""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=0.0001)
|
||||
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
|
||||
bpy.ops.mesh.quads_convert_to_tris() # so len(polygons)==triangle count: the ≤5k budget
|
||||
bpy.ops.object.mode_set(mode='OBJECT') # gate + reported tri counts stay honest (glTF triangulates on export anyway)
|
||||
vs = obj.data.vertices
|
||||
n = len(vs)
|
||||
if n < 20:
|
||||
return 0
|
||||
outliers = []
|
||||
for axis in range(3):
|
||||
vals = sorted(v.co[axis] for v in vs)
|
||||
med = vals[n // 2]
|
||||
mad = sorted(abs(x - med) for x in vals)[n // 2] or 1e-6
|
||||
lo, hi = med - 6 * mad * 1.4826, med + 6 * mad * 1.4826
|
||||
for v in vs:
|
||||
if v.co[axis] < lo or v.co[axis] > hi:
|
||||
outliers.append(v.index)
|
||||
outliers = set(outliers)
|
||||
if outliers and len(outliers) < n * 0.03: # only if they are a tiny minority
|
||||
for v in vs:
|
||||
v.select = v.index in outliers
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.delete(type='VERT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
return len(outliers)
|
||||
return 0
|
||||
|
||||
|
||||
def resize_textures(max_px=1024):
|
||||
for img in bpy.data.images:
|
||||
if img.size[0] > max_px or img.size[1] > max_px:
|
||||
w, h = img.size
|
||||
s = max_px / max(w, h)
|
||||
img.scale(max(1, int(w * s)), max(1, int(h * s)))
|
||||
|
||||
|
||||
def normalize_one(spec):
|
||||
wipe()
|
||||
src = os.path.join(ROOT, spec["src"]) if not os.path.isabs(spec["src"]) else spec["src"]
|
||||
obj = import_join(src)
|
||||
clipped = clean_mesh(obj) # welds, drops loose geo, TRIANGULATES
|
||||
tri_before = len(obj.data.polygons) # true source triangle count (post-triangulate)
|
||||
|
||||
# stand upright (if mis-authored), then face -Z
|
||||
rx, ry, rz = UP_ROT.get(spec.get("up", "none"), (0, 0, 0))
|
||||
obj.rotation_euler = (rx, ry, rz)
|
||||
if spec.get("yaw"):
|
||||
obj.rotation_euler[2] += math.radians(spec["yaw"])
|
||||
bpy.ops.object.transform_apply(rotation=True)
|
||||
|
||||
# solidify: cap open bottoms → watertight, white sole (finish_glb.py:54)
|
||||
if spec.get("solidify"):
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=0.0002)
|
||||
n0 = len(obj.data.polygons)
|
||||
bpy.ops.mesh.fill_holes(sides=0)
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
n1 = len(obj.data.polygons)
|
||||
if n1 > n0:
|
||||
white = bpy.data.materials.new("sole_white"); white.use_nodes = True
|
||||
b = white.node_tree.nodes.get("Principled BSDF")
|
||||
if b:
|
||||
b.inputs["Base Color"].default_value = (1, 1, 1, 1)
|
||||
obj.data.materials.append(white)
|
||||
widx = len(obj.data.materials) - 1
|
||||
for i in range(n0, n1):
|
||||
obj.data.polygons[i].material_index = widx
|
||||
|
||||
# decimate to budget (finish_glb.py:80)
|
||||
tris = len(obj.data.polygons)
|
||||
budget = spec.get("tris", 5000)
|
||||
if tris > budget:
|
||||
m = obj.modifiers.new('decimate', 'DECIMATE')
|
||||
m.ratio = max(0.01, budget / tris)
|
||||
bpy.ops.object.modifier_apply(modifier=m.name)
|
||||
tri_after = len(obj.data.polygons)
|
||||
|
||||
# self-illuminate dark bakes (finish_glb.py:88)
|
||||
if spec.get("bright"):
|
||||
for m in bpy.data.materials:
|
||||
if not m.use_nodes:
|
||||
continue
|
||||
b = m.node_tree.nodes.get("Principled BSDF")
|
||||
if not b:
|
||||
continue
|
||||
bc = b.inputs.get("Base Color"); ec = b.inputs.get("Emission Color")
|
||||
es = b.inputs.get("Emission Strength")
|
||||
if ec is None:
|
||||
continue
|
||||
if bc and bc.is_linked:
|
||||
m.node_tree.links.new(bc.links[0].from_socket, ec)
|
||||
elif bc:
|
||||
ec.default_value = bc.default_value
|
||||
if es is not None:
|
||||
es.default_value = 0.32
|
||||
|
||||
# base-origin + height-normalise (finish_glb.py:109)
|
||||
mn, mx = bounds(obj)
|
||||
h = spec.get("height_m", 0)
|
||||
if h and h > 0:
|
||||
cur = (mx.z - mn.z) or 1.0
|
||||
obj.scale *= (h / cur)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
mn, mx = bounds(obj)
|
||||
base = Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
|
||||
obj.location -= base
|
||||
bpy.ops.object.transform_apply(location=True)
|
||||
mn, mx = bounds(obj)
|
||||
|
||||
resize_textures(1024)
|
||||
|
||||
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
|
||||
out = os.path.join(OUT_DIR, name)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=out, export_format='GLB', export_yup=True, export_apply=True,
|
||||
export_image_format='WEBP', export_image_quality=85,
|
||||
)
|
||||
thumb = render_thumb(name.replace(".glb", ".png"))
|
||||
return {
|
||||
"src": spec["src"], "file": name, "out": out, "thumb": thumb,
|
||||
"tri_before": tri_before, "tri_after": tri_after,
|
||||
"size_m": [round(mx.x - mn.x, 3), round(mx.z - mn.z, 3), round(mx.y - mn.y, 3)],
|
||||
"footprint": [round(mx.x - mn.x, 2), round(mx.y - mn.y, 2)],
|
||||
}
|
||||
|
||||
|
||||
def render_thumb(png_name):
|
||||
"""One 3/4 hero shot, 256px, textured workbench (render_glb.py lighting)."""
|
||||
scn = bpy.context.scene
|
||||
obj = bpy.context.view_layer.objects.active
|
||||
mn, mx = bounds(obj)
|
||||
ctr = (mn + mx) / 2
|
||||
size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type in ('CAMERA', 'LIGHT'):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
|
||||
scn.world = w; w.use_nodes = True
|
||||
w.node_tree.nodes["Background"].inputs[1].default_value = 1.05
|
||||
bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size))
|
||||
bpy.context.object.data.energy = 800 * size; bpy.context.object.data.size = 6
|
||||
bpy.ops.object.camera_add(); cam = bpy.context.object; scn.camera = cam
|
||||
r = size * 1.9
|
||||
cam.location = Vector((ctr.x + r * 0.8, ctr.y - r, ctr.z + size * 0.6))
|
||||
d = ctr - cam.location
|
||||
cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
|
||||
scn.render.engine = 'BLENDER_WORKBENCH'
|
||||
scn.display.shading.light = 'STUDIO'
|
||||
scn.display.shading.color_type = 'TEXTURE'
|
||||
scn.display.shading.show_cavity = True
|
||||
scn.view_settings.view_transform = 'Standard'
|
||||
scn.render.film_transparent = True
|
||||
scn.render.resolution_x = scn.render.resolution_y = 256
|
||||
p = os.path.join(THUMB_DIR, png_name)
|
||||
scn.render.filepath = p
|
||||
bpy.ops.render.render(write_still=True)
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
specs = json.load(open(BATCH))
|
||||
results, errors = [], []
|
||||
for spec in specs:
|
||||
try:
|
||||
r = normalize_one(spec)
|
||||
results.append(r)
|
||||
print(f"OK {r['file']:34} tris {r['tri_before']}→{r['tri_after']:>5} "
|
||||
f"size {r['size_m']} footprint {r['footprint']}")
|
||||
except Exception as e:
|
||||
errors.append({"src": spec.get("src"), "id": spec.get("id"), "error": str(e)})
|
||||
print(f"ERR {spec.get('src')}: {e}")
|
||||
json.dump({"results": results, "errors": errors},
|
||||
open(os.path.join(OUT_DIR, "_results.json"), "w"), indent=2)
|
||||
print(f"\nNORMALIZE_DONE ok={len(results)} err={len(errors)} → {OUT_DIR}/_results.json")
|
||||
|
||||
|
||||
main()
|
||||
79
pipeline/publish.py
Normal file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the normalized PROCITY GLBs + thumbnails to the shared 3GOD depot.
|
||||
|
||||
Mirrors the house push contract (meshgod/server.py:_push_3god) exactly:
|
||||
* auth cookie: 3g = hmac_sha256(GOD3_PW, "3god-ok").hexdigest()
|
||||
* GLB POST {depot}/api/upload?name=<file> body=glb model/gltf-binary
|
||||
* thumb POST {depot}/api/thumb?name=<file> body=png image/png
|
||||
|
||||
Credentials come from the environment — this script never contains or logs the secret:
|
||||
export GOD3_PW=... # the shared depot password (ask John)
|
||||
export GOD3_DEPOT=https://digalot.fyi/3god # optional; this is the default
|
||||
python3 pipeline/publish.py # publish every GLB in _normalized/
|
||||
python3 pipeline/publish.py --dry-run # list what WOULD upload, no network
|
||||
|
||||
This is the one outward-facing step in the lane; run it deliberately. Uploads are
|
||||
idempotent (same name overwrites) and reversible via the depot's own delete.
|
||||
"""
|
||||
import os, sys, glob, hmac, hashlib, json, urllib.request, urllib.parse
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
NORM = os.path.join(ROOT, "pipeline", "_normalized")
|
||||
THUMBS = os.path.join(ROOT, "web", "assets", "thumbs")
|
||||
DEPOT = os.environ.get("GOD3_DEPOT", "https://digalot.fyi/3god").rstrip("/")
|
||||
PW = os.environ.get("GOD3_PW", "")
|
||||
DRY = "--dry-run" in sys.argv
|
||||
# assets intentionally excluded from publish (see AUDIT.md)
|
||||
SKIP = {"procity_fit_cube_shelf_01.glb"}
|
||||
|
||||
|
||||
def cookie():
|
||||
tok = hmac.new(PW.encode(), b"3god-ok", hashlib.sha256).hexdigest()
|
||||
return f"3g={tok}"
|
||||
|
||||
|
||||
def post(path, body, ctype):
|
||||
req = urllib.request.Request(DEPOT + path, data=body, method="POST",
|
||||
headers={"Content-Type": ctype, "Cookie": cookie()})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")[:200]
|
||||
|
||||
|
||||
def main():
|
||||
glbs = sorted(g for g in glob.glob(NORM + "/*.glb") if os.path.basename(g) not in SKIP)
|
||||
if not glbs:
|
||||
print(f"no GLBs in {NORM} — run normalize.py first"); sys.exit(1)
|
||||
print(f"depot: {DEPOT} assets: {len(glbs)} {'(DRY RUN)' if DRY else ''}")
|
||||
if not DRY and not PW:
|
||||
print("ERROR: GOD3_PW not set in environment. Ask John for the depot password, then:\n"
|
||||
" export GOD3_PW=... && python3 pipeline/publish.py")
|
||||
sys.exit(2)
|
||||
published = []
|
||||
for g in glbs:
|
||||
name = os.path.basename(g)
|
||||
thumb = os.path.join(THUMBS, name.replace(".glb", ".png"))
|
||||
size = os.path.getsize(g)
|
||||
if DRY:
|
||||
print(f" would upload {name:38} {size//1024:>5}KB thumb={os.path.exists(thumb)}")
|
||||
continue
|
||||
try:
|
||||
st, msg = post("/api/upload?name=" + urllib.parse.quote(name),
|
||||
open(g, "rb").read(), "model/gltf-binary")
|
||||
tstat = ""
|
||||
if os.path.exists(thumb):
|
||||
ts, _ = post("/api/thumb?name=" + urllib.parse.quote(name.replace(".glb", ".png")),
|
||||
open(thumb, "rb").read(), "image/png")
|
||||
tstat = f" thumb:{ts}"
|
||||
ok = st in (200, 201)
|
||||
print(f" {'OK ' if ok else 'ERR'} {name:38} glb:{st}{tstat}")
|
||||
if ok:
|
||||
published.append(name)
|
||||
except Exception as e:
|
||||
print(f" ERR {name:38} {e}")
|
||||
if not DRY:
|
||||
json.dump(published, open(os.path.join(ROOT, "pipeline", "_published.json"), "w"), indent=2)
|
||||
print(f"\npublished {len(published)}/{len(glbs)} → depot. verify: curl -s {DEPOT}/api/list")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
129
pipeline/validate_manifest.py
Normal file
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate web/assets/manifest.json — runs in Lane F's integration gate.
|
||||
|
||||
Checks: JSON parses; every referenced skin exists locally; every fitting/furniture GLB
|
||||
exists locally (pipeline/_normalized) OR HEADs 200 on the depot; every thumb exists;
|
||||
footprints & heights are sane; every registry shop type has >=2 facades.
|
||||
|
||||
python3 pipeline/validate_manifest.py # local + soft depot check
|
||||
python3 pipeline/validate_manifest.py --depot # require GLBs live on the depot (post-publish)
|
||||
|
||||
Exit 0 = green, 1 = fail. Plain stdlib (no deps), so it runs anywhere.
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ASSETS = os.path.join(ROOT, "web", "assets")
|
||||
NORM = os.path.join(ROOT, "pipeline", "_normalized")
|
||||
MANIFEST = os.path.join(ASSETS, "manifest.json")
|
||||
REGISTRY_TYPES = ["record", "opshop", "toy", "book", "video", "pawn", "milkbar", "dept", "stall"]
|
||||
STRICT_DEPOT = "--depot" in sys.argv
|
||||
|
||||
errors, warnings = [], []
|
||||
|
||||
|
||||
def err(m): errors.append(m)
|
||||
def warn(m): warnings.append(m)
|
||||
|
||||
|
||||
def head_ok(url):
|
||||
# The depot contract documents GET only, not HEAD — probe with a 1-byte Range GET so we don't
|
||||
# download the whole GLB and don't depend on HEAD being supported by the CDN/cache.
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
|
||||
with urllib.request.urlopen(req, timeout=12) as r:
|
||||
return r.status in (200, 206)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 416: # range not satisfiable but the file exists
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_skin(file):
|
||||
p = os.path.join(ASSETS, file)
|
||||
if not os.path.isfile(p):
|
||||
err(f"skin missing: {file}")
|
||||
|
||||
|
||||
def check_glb(entry, depot):
|
||||
file = entry["file"]
|
||||
local = os.path.join(NORM, file)
|
||||
on_disk = os.path.isfile(local)
|
||||
live = head_ok(f"{depot}/a/{file}")
|
||||
if STRICT_DEPOT and not live:
|
||||
err(f"GLB not live on depot: {file}")
|
||||
elif not on_disk and not live:
|
||||
err(f"GLB missing (not local, not on depot): {file}")
|
||||
elif not live:
|
||||
warn(f"GLB not yet published to depot (local only): {file}")
|
||||
# thumb
|
||||
thumb = entry.get("thumb")
|
||||
if thumb and not os.path.isfile(os.path.join(ASSETS, thumb)):
|
||||
err(f"thumb missing: {thumb}")
|
||||
# footprint / height sanity
|
||||
fp = entry.get("footprint")
|
||||
if not (isinstance(fp, list) and len(fp) == 2 and all(0 < x < 12 for x in fp)):
|
||||
err(f"insane footprint for {file}: {fp}")
|
||||
h = entry.get("height")
|
||||
if not (isinstance(h, (int, float)) and 0 < h < 8):
|
||||
err(f"insane height for {file}: {h}")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
m = json.load(open(MANIFEST))
|
||||
except Exception as e:
|
||||
print(f"FAIL: manifest does not parse: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
depot = m.get("depot", "https://digalot.fyi/3god")
|
||||
sk = m.get("skins", {})
|
||||
|
||||
# facades + type coverage
|
||||
facade = sk.get("facade", {})
|
||||
for k, v in facade.items():
|
||||
check_skin(v["file"])
|
||||
for t in REGISTRY_TYPES:
|
||||
n = sum(1 for v in facade.values() if t in v.get("types", []))
|
||||
if n < 2:
|
||||
err(f"shop type '{t}' has only {n} facade(s) (need >=2)")
|
||||
|
||||
# other skin groups
|
||||
for s in sk.get("sky", []):
|
||||
check_skin(s["file"])
|
||||
for g in sk.get("ground", {}).values():
|
||||
check_skin(g["file"])
|
||||
for w in sk.get("wall", []):
|
||||
check_skin(w["file"])
|
||||
interior = sk.get("interior", {})
|
||||
for grp in ("floor", "surface"):
|
||||
for it in interior.get(grp, []):
|
||||
check_skin(it["file"])
|
||||
for a in sk.get("awning", []):
|
||||
check_skin(a["file"])
|
||||
|
||||
# fittings + furniture GLBs
|
||||
n_glb = 0
|
||||
for grp in ("fittings", "furniture"):
|
||||
for entry in m.get(grp, {}).values():
|
||||
check_glb(entry, depot)
|
||||
n_glb += 1
|
||||
|
||||
print(f"manifest v{m.get('version')} — facades {len(facade)}, "
|
||||
f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, "
|
||||
f"GLBs {n_glb}")
|
||||
for w in warnings:
|
||||
print(f" WARN {w}")
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f" ERR {e}")
|
||||
print(f"\nFAIL — {len(errors)} error(s), {len(warnings)} warning(s)")
|
||||
sys.exit(1)
|
||||
print(f"\nOK — 0 errors, {len(warnings)} warning(s)")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
web/assets/gen/facade-arcade-entry.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
web/assets/gen/facade-book-barn.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
web/assets/gen/facade-book-scholarly.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
web/assets/gen/facade-brick-side.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
web/assets/gen/facade-dept-anchor.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
web/assets/gen/facade-dept-emporium.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
web/assets/gen/facade-market-edge.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
web/assets/gen/facade-market-fruit.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
web/assets/gen/facade-milkbar-corner.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
web/assets/gen/facade-milkbar-deco.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
web/assets/gen/facade-milkbar-fibro.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
web/assets/gen/facade-render-side.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
web/assets/gen/facade-res-brickveneer.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
web/assets/gen/facade-res-fibro.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
web/assets/gen/facade-res-terrace.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
web/assets/gen/facade-res-weatherboard.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
web/assets/gen/facade-toy-brights.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
web/assets/gen/facade-toy-lolly.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
web/assets/gen/facade-video-neon.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
web/assets/gen/facade-video-suburban.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
web/assets/gen/facade-warehouse-roller.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
web/assets/gen/facade-warehouse-tin.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
web/assets/gen/facade-weatherboard-side.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
730
web/assets/manifest.json
Normal file
@ -0,0 +1,730 @@
|
||||
{
|
||||
"version": 1,
|
||||
"depot": "https://digalot.fyi/3god",
|
||||
"conventions": {
|
||||
"glb_law": "metres, +Y up, origin at base (minY=0, centred X/Z), <=5k tris, WebP <=1024, no Draco",
|
||||
"facing": "symmetric props are orientation-agnostic; normalize.py applies no automatic facing-correction (per-asset yaw is available in batch.json) \u2014 Lane B/C rotate directional props (counter, food_cart) to taste when placing",
|
||||
"facade_blank_signboard": "facades tagged signboard:'blank' ship a blank sign panel \u2014 Lane B overlays the shop name there. signboard:'baked' facades (some djsim-* Flux skins) already have sign text: use as-is, don't overlay",
|
||||
"facade_face": "facade entries without a 'face' key are front elevations (shop or house fronts); face:'side' entries are windowless corner-lot side walls with empty types \u2014 never select one as a shop front",
|
||||
"corner_lots": "corner lots expose a side wall: dedicated facade-<x>-side.jpg skins now exist for brick/render/weatherboard (face:'side') \u2014 Lane B maps these to the exposed side face; other types reuse the front skin",
|
||||
"fallbacks": "every asset is optional \u2014 missing file => primitive/flat-colour, never a crash",
|
||||
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>"
|
||||
},
|
||||
"skins": {
|
||||
"facade": {
|
||||
"timber-teal": {
|
||||
"file": "gen/facade-timber-teal.jpg",
|
||||
"types": [
|
||||
"record"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"arcade-tile": {
|
||||
"file": "gen/facade-arcade-tile.jpg",
|
||||
"types": [
|
||||
"record"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"djsim-record": {
|
||||
"file": "gen/facade-djsim-record.jpg",
|
||||
"types": [
|
||||
"record"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"weatherboard": {
|
||||
"file": "gen/facade-weatherboard.jpg",
|
||||
"types": [
|
||||
"opshop"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"fibro-blue": {
|
||||
"file": "gen/facade-fibro-blue.jpg",
|
||||
"types": [
|
||||
"opshop"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"djsim-opshop": {
|
||||
"file": "gen/facade-djsim-opshop.jpg",
|
||||
"types": [
|
||||
"opshop"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"stucco-pink": {
|
||||
"file": "gen/facade-stucco-pink.jpg",
|
||||
"types": [
|
||||
"toy"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"deco-pastel": {
|
||||
"file": "gen/facade-deco-pastel.jpg",
|
||||
"types": [
|
||||
"toy"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"boutique": {
|
||||
"file": "gen/facade-boutique.jpg",
|
||||
"types": [
|
||||
"toy"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"federation": {
|
||||
"file": "gen/facade-federation.jpg",
|
||||
"types": [
|
||||
"book"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"sandstone": {
|
||||
"file": "gen/facade-sandstone.jpg",
|
||||
"types": [
|
||||
"book"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"redbrick": {
|
||||
"file": "gen/facade-redbrick.jpg",
|
||||
"types": [
|
||||
"book"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"stripmall": {
|
||||
"file": "gen/facade-stripmall.jpg",
|
||||
"types": [
|
||||
"video"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"djsim-video": {
|
||||
"file": "gen/facade-djsim-video.jpg",
|
||||
"types": [
|
||||
"video"
|
||||
],
|
||||
"signboard": "baked"
|
||||
},
|
||||
"besser": {
|
||||
"file": "gen/facade-besser.jpg",
|
||||
"types": [
|
||||
"pawn"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"grimy": {
|
||||
"file": "gen/facade-grimy.jpg",
|
||||
"types": [
|
||||
"pawn"
|
||||
],
|
||||
"signboard": "baked"
|
||||
},
|
||||
"djsim-pawn": {
|
||||
"file": "gen/facade-djsim-pawn.jpg",
|
||||
"types": [
|
||||
"pawn"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"corrugated": {
|
||||
"file": "gen/facade-corrugated.jpg",
|
||||
"types": [
|
||||
"pawn",
|
||||
"stall"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"cashtheists": {
|
||||
"file": "gen/facade-cashtheists.jpg",
|
||||
"types": [
|
||||
"pawn"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"djsim-milkbar": {
|
||||
"file": "gen/facade-djsim-milkbar.jpg",
|
||||
"types": [
|
||||
"milkbar"
|
||||
],
|
||||
"signboard": "baked"
|
||||
},
|
||||
"brickveneer": {
|
||||
"file": "gen/facade-brickveneer.jpg",
|
||||
"types": [
|
||||
"milkbar"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"terrazzo": {
|
||||
"file": "gen/facade-terrazzo.jpg",
|
||||
"types": [
|
||||
"dept"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"djsim-dept": {
|
||||
"file": "gen/facade-djsim-dept.jpg",
|
||||
"types": [
|
||||
"dept"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"market": {
|
||||
"file": "gen/facade-market.jpg",
|
||||
"types": [
|
||||
"stall"
|
||||
],
|
||||
"signboard": "baked"
|
||||
},
|
||||
"djsim-pub": {
|
||||
"file": "gen/facade-djsim-pub.jpg",
|
||||
"types": [
|
||||
"general"
|
||||
],
|
||||
"signboard": "baked"
|
||||
},
|
||||
"toy-brights": {
|
||||
"file": "gen/facade-toy-brights.jpg",
|
||||
"types": [
|
||||
"toy"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"toy-lolly": {
|
||||
"file": "gen/facade-toy-lolly.jpg",
|
||||
"types": [
|
||||
"toy"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"book-barn": {
|
||||
"file": "gen/facade-book-barn.jpg",
|
||||
"types": [
|
||||
"book"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"book-scholarly": {
|
||||
"file": "gen/facade-book-scholarly.jpg",
|
||||
"types": [
|
||||
"book"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"video-neon": {
|
||||
"file": "gen/facade-video-neon.jpg",
|
||||
"types": [
|
||||
"video"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"video-suburban": {
|
||||
"file": "gen/facade-video-suburban.jpg",
|
||||
"types": [
|
||||
"video"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"milkbar-corner": {
|
||||
"file": "gen/facade-milkbar-corner.jpg",
|
||||
"types": [
|
||||
"milkbar"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"milkbar-fibro": {
|
||||
"file": "gen/facade-milkbar-fibro.jpg",
|
||||
"types": [
|
||||
"milkbar"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"milkbar-deco": {
|
||||
"file": "gen/facade-milkbar-deco.jpg",
|
||||
"types": [
|
||||
"milkbar"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"dept-anchor": {
|
||||
"file": "gen/facade-dept-anchor.jpg",
|
||||
"types": [
|
||||
"dept"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"dept-emporium": {
|
||||
"file": "gen/facade-dept-emporium.jpg",
|
||||
"types": [
|
||||
"dept"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"market-fruit": {
|
||||
"file": "gen/facade-market-fruit.jpg",
|
||||
"types": [
|
||||
"stall"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"market-edge": {
|
||||
"file": "gen/facade-market-edge.jpg",
|
||||
"types": [
|
||||
"stall"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"warehouse-roller": {
|
||||
"file": "gen/facade-warehouse-roller.jpg",
|
||||
"types": [
|
||||
"pawn",
|
||||
"general"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"warehouse-tin": {
|
||||
"file": "gen/facade-warehouse-tin.jpg",
|
||||
"types": [
|
||||
"pawn",
|
||||
"general"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"arcade-entry": {
|
||||
"file": "gen/facade-arcade-entry.jpg",
|
||||
"types": [
|
||||
"general"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"res-terrace": {
|
||||
"file": "gen/facade-res-terrace.jpg",
|
||||
"types": [
|
||||
"house"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"res-weatherboard": {
|
||||
"file": "gen/facade-res-weatherboard.jpg",
|
||||
"types": [
|
||||
"house"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"res-fibro": {
|
||||
"file": "gen/facade-res-fibro.jpg",
|
||||
"types": [
|
||||
"house"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"res-brickveneer": {
|
||||
"file": "gen/facade-res-brickveneer.jpg",
|
||||
"types": [
|
||||
"house"
|
||||
],
|
||||
"signboard": "blank"
|
||||
},
|
||||
"brick-side": {
|
||||
"file": "gen/facade-brick-side.jpg",
|
||||
"types": [],
|
||||
"signboard": "blank",
|
||||
"face": "side"
|
||||
},
|
||||
"render-side": {
|
||||
"file": "gen/facade-render-side.jpg",
|
||||
"types": [],
|
||||
"signboard": "blank",
|
||||
"face": "side"
|
||||
},
|
||||
"weatherboard-side": {
|
||||
"file": "gen/facade-weatherboard-side.jpg",
|
||||
"types": [],
|
||||
"signboard": "blank",
|
||||
"face": "side"
|
||||
}
|
||||
},
|
||||
"sky": [
|
||||
{
|
||||
"id": "cold-front",
|
||||
"file": "gen/sky-cold-front.jpg"
|
||||
},
|
||||
{
|
||||
"id": "endless-blue",
|
||||
"file": "gen/sky-endless-blue.jpg"
|
||||
},
|
||||
{
|
||||
"id": "golden-arvo",
|
||||
"file": "gen/sky-golden-arvo.jpg"
|
||||
},
|
||||
{
|
||||
"id": "grey-drizzle",
|
||||
"file": "gen/sky-grey-drizzle.jpg"
|
||||
},
|
||||
{
|
||||
"id": "heat-haze",
|
||||
"file": "gen/sky-heat-haze.jpg"
|
||||
},
|
||||
{
|
||||
"id": "high-cirrus",
|
||||
"file": "gen/sky-high-cirrus.jpg"
|
||||
},
|
||||
{
|
||||
"id": "monsoon",
|
||||
"file": "gen/sky-monsoon.jpg"
|
||||
},
|
||||
{
|
||||
"id": "outback-dusk",
|
||||
"file": "gen/sky-outback-dusk.jpg"
|
||||
},
|
||||
{
|
||||
"id": "spring-puffy",
|
||||
"file": "gen/sky-spring-puffy.jpg"
|
||||
},
|
||||
{
|
||||
"id": "summer-storm",
|
||||
"file": "gen/sky-summer-storm.jpg"
|
||||
}
|
||||
],
|
||||
"ground": {
|
||||
"asphalt1": {
|
||||
"file": "gen/ground-asphalt1.jpg",
|
||||
"use": "road"
|
||||
},
|
||||
"asphalt2": {
|
||||
"file": "gen/ground-asphalt2.jpg",
|
||||
"use": "road"
|
||||
},
|
||||
"brickpave": {
|
||||
"file": "gen/ground-brickpave.jpg",
|
||||
"use": "plaza"
|
||||
},
|
||||
"djsim-footpath": {
|
||||
"file": "gen/ground-djsim-footpath.jpg",
|
||||
"use": "footpath"
|
||||
},
|
||||
"djsim-road": {
|
||||
"file": "gen/ground-djsim-road.jpg",
|
||||
"use": "road"
|
||||
},
|
||||
"footpath1": {
|
||||
"file": "gen/ground-footpath1.jpg",
|
||||
"use": "footpath"
|
||||
},
|
||||
"footpath2": {
|
||||
"file": "gen/ground-footpath2.jpg",
|
||||
"use": "footpath"
|
||||
},
|
||||
"grass": {
|
||||
"file": "gen/ground-grass.jpg",
|
||||
"use": "yard"
|
||||
},
|
||||
"gravel": {
|
||||
"file": "gen/ground-gravel.jpg",
|
||||
"use": "verge"
|
||||
},
|
||||
"reddust": {
|
||||
"file": "gen/ground-reddust.jpg",
|
||||
"use": "outback"
|
||||
}
|
||||
},
|
||||
"wall": [
|
||||
{
|
||||
"id": "damask-mauve",
|
||||
"file": "gen/wall-damask-mauve.jpg"
|
||||
},
|
||||
{
|
||||
"id": "diamond-green",
|
||||
"file": "gen/wall-diamond-green.jpg"
|
||||
},
|
||||
{
|
||||
"id": "floral-cream",
|
||||
"file": "gen/wall-floral-cream.jpg"
|
||||
},
|
||||
{
|
||||
"id": "floral-gold",
|
||||
"file": "gen/wall-floral-gold.jpg"
|
||||
},
|
||||
{
|
||||
"id": "geo-orange",
|
||||
"file": "gen/wall-geo-orange.jpg"
|
||||
},
|
||||
{
|
||||
"id": "stripe-sage",
|
||||
"file": "gen/wall-stripe-sage.jpg"
|
||||
},
|
||||
{
|
||||
"id": "trellis-blue",
|
||||
"file": "gen/wall-trellis-blue.jpg"
|
||||
},
|
||||
{
|
||||
"id": "woodchip-white",
|
||||
"file": "gen/wall-woodchip-white.jpg"
|
||||
}
|
||||
],
|
||||
"interior": {
|
||||
"floor": [
|
||||
{
|
||||
"id": "carpet-swirl",
|
||||
"file": "gen/tex-carpet-swirl.jpg"
|
||||
},
|
||||
{
|
||||
"id": "carpet-mustard",
|
||||
"file": "gen/tex-carpet-mustard.jpg"
|
||||
},
|
||||
{
|
||||
"id": "carpet-greygreen",
|
||||
"file": "gen/tex-carpet-greygreen.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lino-check",
|
||||
"file": "gen/tex-lino-check.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lino-cork",
|
||||
"file": "gen/tex-lino-cork.jpg"
|
||||
},
|
||||
{
|
||||
"id": "boards-polished",
|
||||
"file": "gen/tex-boards-polished.jpg"
|
||||
}
|
||||
],
|
||||
"surface": [
|
||||
{
|
||||
"id": "wood-counter",
|
||||
"file": "gen/tex-wood-counter.jpg",
|
||||
"types": [
|
||||
"record",
|
||||
"milkbar",
|
||||
"pawn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "boutique-wall",
|
||||
"file": "gen/tex-boutique-wall.jpg",
|
||||
"types": [
|
||||
"toy",
|
||||
"opshop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pegboard",
|
||||
"file": "gen/tex-pegboard.jpg",
|
||||
"types": [
|
||||
"pawn",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brick",
|
||||
"file": "gen/tex-brick.jpg",
|
||||
"types": [
|
||||
"general"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "render",
|
||||
"file": "gen/tex-render.jpg",
|
||||
"types": [
|
||||
"general"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "grimy-wall",
|
||||
"file": "gen/tex-grimy-wall.jpg",
|
||||
"types": [
|
||||
"pawn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "velvet-curtain",
|
||||
"file": "gen/tex-velvet-curtain.jpg",
|
||||
"types": [
|
||||
"toy",
|
||||
"record"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"awning": [
|
||||
{
|
||||
"id": "awning-red",
|
||||
"file": "gen/tex-awning-red.jpg"
|
||||
},
|
||||
{
|
||||
"id": "awning-green",
|
||||
"file": "gen/tex-awning-green.jpg"
|
||||
},
|
||||
{
|
||||
"id": "awning-blue",
|
||||
"file": "gen/tex-awning-blue.jpg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fittings": {
|
||||
"record_crate": {
|
||||
"file": "procity_fit_record_crate_01.glb",
|
||||
"footprint": [
|
||||
0.46,
|
||||
0.63
|
||||
],
|
||||
"height": 0.41,
|
||||
"thumb": "thumbs/procity_fit_record_crate_01.png"
|
||||
},
|
||||
"wire_shelf": {
|
||||
"file": "procity_fit_wire_shelf_01.glb",
|
||||
"footprint": [
|
||||
1.17,
|
||||
0.44
|
||||
],
|
||||
"height": 1.8,
|
||||
"thumb": "thumbs/procity_fit_wire_shelf_01.png"
|
||||
},
|
||||
"clothes_rack": {
|
||||
"file": "procity_fit_clothes_rack_01.glb",
|
||||
"footprint": [
|
||||
1.34,
|
||||
0.43
|
||||
],
|
||||
"height": 1.5,
|
||||
"thumb": "thumbs/procity_fit_clothes_rack_01.png"
|
||||
},
|
||||
"bookshelf": {
|
||||
"file": "procity_fit_bookshelf_01.glb",
|
||||
"footprint": [
|
||||
0.2,
|
||||
1.02
|
||||
],
|
||||
"height": 2.0,
|
||||
"thumb": "thumbs/procity_fit_bookshelf_01.png"
|
||||
},
|
||||
"cube_shelf_wide": {
|
||||
"file": "procity_fit_cube_shelf_wide_01.glb",
|
||||
"footprint": [
|
||||
0.75,
|
||||
1.24
|
||||
],
|
||||
"height": 1.1,
|
||||
"thumb": "thumbs/procity_fit_cube_shelf_wide_01.png"
|
||||
},
|
||||
"counter": {
|
||||
"file": "procity_fit_counter_01.glb",
|
||||
"footprint": [
|
||||
1.06,
|
||||
4.02
|
||||
],
|
||||
"height": 1.1,
|
||||
"thumb": "thumbs/procity_fit_counter_01.png"
|
||||
},
|
||||
"coffee_table": {
|
||||
"file": "procity_fit_coffee_table_01.glb",
|
||||
"footprint": [
|
||||
0.67,
|
||||
1.49
|
||||
],
|
||||
"height": 0.45,
|
||||
"thumb": "thumbs/procity_fit_coffee_table_01.png"
|
||||
},
|
||||
"work_table": {
|
||||
"file": "procity_fit_work_table_01.glb",
|
||||
"footprint": [
|
||||
0.44,
|
||||
1.04
|
||||
],
|
||||
"height": 0.75,
|
||||
"thumb": "thumbs/procity_fit_work_table_01.png"
|
||||
},
|
||||
"box_crate": {
|
||||
"file": "procity_fit_box_crate_01.glb",
|
||||
"footprint": [
|
||||
1.35,
|
||||
1.32
|
||||
],
|
||||
"height": 0.6,
|
||||
"thumb": "thumbs/procity_fit_box_crate_01.png"
|
||||
}
|
||||
},
|
||||
"furniture": {
|
||||
"bench": {
|
||||
"file": "procity_street_bench_01.glb",
|
||||
"footprint": [
|
||||
0.44,
|
||||
1.21
|
||||
],
|
||||
"height": 0.85,
|
||||
"thumb": "thumbs/procity_street_bench_01.png"
|
||||
},
|
||||
"bench_modern": {
|
||||
"file": "procity_street_bench_modern_01.glb",
|
||||
"footprint": [
|
||||
1.68,
|
||||
0.47
|
||||
],
|
||||
"height": 0.45,
|
||||
"thumb": "thumbs/procity_street_bench_modern_01.png"
|
||||
},
|
||||
"bench_wood": {
|
||||
"file": "procity_street_bench_wood_01.glb",
|
||||
"footprint": [
|
||||
1.34,
|
||||
0.59
|
||||
],
|
||||
"height": 0.85,
|
||||
"thumb": "thumbs/procity_street_bench_wood_01.png"
|
||||
},
|
||||
"longbench": {
|
||||
"file": "procity_street_longbench_01.glb",
|
||||
"footprint": [
|
||||
5.86,
|
||||
1.08
|
||||
],
|
||||
"height": 0.85,
|
||||
"thumb": "thumbs/procity_street_longbench_01.png"
|
||||
},
|
||||
"park_bench": {
|
||||
"file": "procity_street_park_bench_01.glb",
|
||||
"footprint": [
|
||||
1.92,
|
||||
1.61
|
||||
],
|
||||
"height": 0.85,
|
||||
"thumb": "thumbs/procity_street_park_bench_01.png"
|
||||
},
|
||||
"streetlight": {
|
||||
"file": "procity_street_streetlight_01.glb",
|
||||
"footprint": [
|
||||
1.64,
|
||||
1.82
|
||||
],
|
||||
"height": 5.0,
|
||||
"thumb": "thumbs/procity_street_streetlight_01.png"
|
||||
},
|
||||
"food_cart": {
|
||||
"file": "procity_street_food_cart_01.glb",
|
||||
"footprint": [
|
||||
3.0,
|
||||
2.07
|
||||
],
|
||||
"height": 2.0,
|
||||
"thumb": "thumbs/procity_street_food_cart_01.png"
|
||||
}
|
||||
},
|
||||
"fittingAliases": {
|
||||
"record_bins": "record_crate",
|
||||
"crates": "box_crate",
|
||||
"counter": "counter",
|
||||
"clothes_racks": "clothes_rack",
|
||||
"bricabrac_shelving": "wire_shelf",
|
||||
"book_wall": "bookshelf",
|
||||
"cube_shelves": "cube_shelf_wide",
|
||||
"display_tables": "coffee_table",
|
||||
"bookshelves": "bookshelf",
|
||||
"vhs_shelving": "wire_shelf",
|
||||
"trestle_tables": "work_table"
|
||||
}
|
||||
}
|
||||
BIN
web/assets/thumbs/procity_fit_bookshelf_01.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
web/assets/thumbs/procity_fit_box_crate_01.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
web/assets/thumbs/procity_fit_clothes_rack_01.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/assets/thumbs/procity_fit_coffee_table_01.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
web/assets/thumbs/procity_fit_counter_01.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
web/assets/thumbs/procity_fit_cube_shelf_wide_01.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
web/assets/thumbs/procity_fit_record_crate_01.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
web/assets/thumbs/procity_fit_wire_shelf_01.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
web/assets/thumbs/procity_fit_work_table_01.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
web/assets/thumbs/procity_street_bench_01.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/assets/thumbs/procity_street_bench_modern_01.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
web/assets/thumbs/procity_street_bench_wood_01.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
web/assets/thumbs/procity_street_food_cart_01.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
web/assets/thumbs/procity_street_longbench_01.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
web/assets/thumbs/procity_street_park_bench_01.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/assets/thumbs/procity_street_streetlight_01.png
Normal file
|
After Width: | Height: | Size: 27 KiB |