Compare commits
No commits in common. "1a20501e552e85044d4c7fae94f1542e084076d4" and "862bbcd8debf07ee1a2931ea812e9a0ecd16938c" have entirely different histories.
1a20501e55
...
862bbcd8de
6
.gitignore
vendored
@ -7,9 +7,3 @@ pipeline/_normalized/
|
||||
saves/
|
||||
node_modules/
|
||||
.env
|
||||
pipeline/.genprops/
|
||||
web/assets/models/
|
||||
pipeline/.peds_orig/
|
||||
pipeline/.props_orig/
|
||||
pipeline/.peds_r6/
|
||||
pipeline/_stockpack/
|
||||
|
||||
@ -4,74 +4,6 @@
|
||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
||||
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
|
||||
|
||||
## Round 6 (Fable's ROUND6 → Lane B) — placed the orphaned street props · qa GREEN
|
||||
|
||||
Wired the two published-but-unconsumed street GLBs (`street_bin_01`, `bus_shelter_01`) via the
|
||||
use-if-ready furniture path (fail-soft; `?noassets` → primitives, 0 network). Files: `furniture.js`,
|
||||
`chunks.js` (collider merge).
|
||||
- **Bins**: sparse (~1/block, `s += 78 m`, seeded), decoupled from benches — country town not a mall.
|
||||
- **Bus shelters on the street graph** (future tram/bus stops): deterministic rule = midpoint of every
|
||||
MAIN edge with `hashEdge(id)%3===0`, road-side footpath, long axis along the edge, open front to road
|
||||
→ **1–3/town** (2 in seed 20261990). **Solid collider** (furniture now returns colliders → `chunks`
|
||||
merges into `getColliders`); walk-into-it is blocked. **Exposed `busShelterStops(plan)` → the stop
|
||||
list contract** for whoever builds the vehicle loop.
|
||||
- **Perf**: worst view WITH rig-fleet citizens **94k tris / 148 draws** (≤200k / ≤300, big margin);
|
||||
deterministic; all-flags-on composes clean (0 errors). Flagged the 3k-tri `street_bin` GLB to Lane E as
|
||||
a decimation candidate (bounded by the sparse cadence for now). Shot: `docs/shots/laneB/bus_shelter.png`.
|
||||
|
||||
## Round 5 (Fable's ROUND5 → Lane B) — v2 window trick, behind `?winmap=1` · qa GREEN (4/4)
|
||||
|
||||
- **Parallax interior-mapping window glass** (V2_IDEAS "Vuntra window trick"), behind **`?winmap=1`
|
||||
default-off.** One shared `ShaderMaterial` on the existing window geometry does single-cube interior
|
||||
mapping — each fragment rays into a virtual room box and shades back wall + seeded shelves + side
|
||||
walls + floor/ceiling. **Zero extra geometry, 1 window draw/chunk, per-shop variation via attributes
|
||||
(aTangent/aTint/aSeed), not per-shop materials.** Seeded from `shop.seed`.
|
||||
- **§3.5 held:** night → open shop's room glows warm, closed shop's room dark (reuses the closed-facade
|
||||
`uHour/uNight/aHours`). Screenshots: `docs/shots/laneB/winmap_{day,night_open_vs_closed,night_closed}.png`.
|
||||
- **v2 prime law held & proven:** flag-OFF byte-identical to v1 (windows `MeshStandardMaterial`, no
|
||||
winmap attrs); flag-ON worst view **122–168 draws** (≤300); shader **100% procedural — 0 fetch delta**
|
||||
vs `?noassets` baseline; 0 console errors; `GOLDEN.hash` untouched. `?winmap` is self-read in
|
||||
buildings.js → **no index.html seam for F** (just a flags-table row). File: `buildings.js`.
|
||||
|
||||
## Round 4 (Fable's ROUND4 → Lane B) — done · qa.sh --strict GREEN (4/4)
|
||||
|
||||
- **B1 — closed-facade visuals (§3.5, headline).** Closed shops → **dark windows + red CLOSED plate**;
|
||||
open shops (esp. the `openLate` video rental at night) → **lit windows**; door tooltip
|
||||
`🔒 <name> — CLOSED · opens HH:00`. Batched with a per-vertex `aHours` + two shared shaders + one
|
||||
`uHour/uNight` uniform pair, updated once on a `procity:segment` event (lighting.js → buildings.js)
|
||||
— **facade atlas untouched, no per-shop materials, no per-frame poll.** Verified live: **22:00 →
|
||||
only "Video Regal" lit, all others CLOSED**; midday → all open; worst-view **200 draws with
|
||||
citizens** (≤300). Files: `buildings.js`, `lighting.js`, `hud.js`.
|
||||
- **B2 — shot harness (gate-6 critical path).** The "letterbox" was **not** a composer bug (proved
|
||||
the composer is correctly sized headless) — the `street_noon`/`arcade`/`warehouse_fringe` **poses
|
||||
jammed the camera into a wall**. Reframed them to look down a street (new `streetViewPose`), added
|
||||
the **3 missing bookmarks** F asked for, pointed `night_neon` at the glowing video shop amid CLOSED
|
||||
neighbours, and added a defensive composer resize. `tools/shots.py` → **10 un-letterboxed shots, 0
|
||||
console errors** (in `docs/shots/laneB_r4/`). File: `dbg.js`.
|
||||
- **B3 — furniture GLB upgrade + novelty_record.** furniture.js now use-if-ready-upgrades primitives
|
||||
to depot GLBs (sync check, `?noassets` → 0 network verified). Placed **novelty_record** at every
|
||||
record store + food_cart by stalls/milk bars. **Finding:** the **novelty_record GLB is 26.5k tris**
|
||||
(5× `glb_law`), spiking a busy view to **574k** → it stays on its low-poly primitive; bench (1.9k)
|
||||
+ food_cart (5.0k) GLBs are fine and sit on their footprints. **→ Lane E: decimate novelty_record
|
||||
(same as the ped rigs).** File: `furniture.js`.
|
||||
|
||||
|
||||
## Round 3 (Fable's ROUND3_INSTRUCTIONS → Lane B) — done
|
||||
- **Task 1 (draw budget, re-measured WITH citizens).** Radius-2 auto is already the spawn default;
|
||||
no config change needed. Drove the real loop (`chunks.update` + `citizens.update`, pop 140, up to
|
||||
**125 active peds**) along the dense corridors via `window.DBG`: worst continuous-walk view
|
||||
**263 scene / 275 total at MIDDAY**, **275 total at NIGHT** — **≤300 everywhere**, ~25 margin.
|
||||
Peds are a batched impostor layer (~1–2 draws). Documented in LANE_B_NOTES.
|
||||
- **Task 2 (`scene.environment`).** Was **null** → Lane D's rig-fleet peds bake their impostor atlas
|
||||
from it (`sim.js`) and would render dark. Set a neutral PMREM'd sky→ground gradient in
|
||||
`lighting.attachRenderer` (before `CitizenSim` constructs; no asset). Verified **set in
|
||||
street / night / interior / after interior-return** (`interior_mode` only swaps `scene.background`).
|
||||
- **Task 3 (furniture GLB footprints)** — **standby**: Fable's §3.4 (manifest GLB upgrade +
|
||||
`?noassets`) hasn't landed (furniture.js still primitives); will verify placement/collision when it does.
|
||||
- **Task 4 (optional polish)** — deferred (fresh generated-city stills / map.html wiring).
|
||||
- Only Lane B file changed this round: `web/js/world/lighting.js` (+ these docs). Committed by exact
|
||||
pathspec (Lanes C/E/F have concurrent uncommitted work in the shared tree — untouched).
|
||||
|
||||
## Session 2 (continuation) — closed the two open Lane-B items Fable flagged
|
||||
- **Draw budget now holds with margin.** Facade texture atlas + awning-merge + shell/trim-merge:
|
||||
worst dense frustum **393 → 265 scene draws**, worst continuous-walk gameplay view **~261 total**
|
||||
|
||||
149
C-progress.md
@ -3,154 +3,7 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-14 (round 7) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 7) — `?stock=real`: real GODVERSE sleeves in the crates
|
||||
|
||||
Round-7 §Lane C: the `stockAdapter` seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody-
|
||||
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind `?stock=real`.
|
||||
- **Contract handshake** (task 1, unblocked E): confirmed E's index schema as-is in
|
||||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) — `{ items:[{id,title,artist,price,price_band,atlas,uv}] }`,
|
||||
UV origin top-left (C flips V for WebGL), fail-soft, per-type packs.
|
||||
- **New `stockpack.js`**: `preloadStockPack`/`getStockPack` (cached) + `makeStockAdapter`. Atlas texture +
|
||||
material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's
|
||||
baked UV → all real sleeves in a room **batch to one draw** (record: 59 draws).
|
||||
- **Consumers**: `stock.js binFan` builds real cover planes (else parody canvas); `dig.js` riffles real
|
||||
covers and the pull card shows **title / artist / $price / band**; `interiors.js` wires `opts.stock='real'`
|
||||
through the seam (warn-once + parody fallback if the pack is absent); test page reads `?stock=real`.
|
||||
- **Validated (sample 24-pack)**: determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles —
|
||||
atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop
|
||||
ok → parody), `?dig=1&stock=real` leak-free. Flag-off + `?noassets` untouched (procedural path byte-identical).
|
||||
- Built against E's **staged sample** (real index format, synthetic covers); real Discogs covers drop in
|
||||
unchanged when E gets the DB (external — John/Fable). Shot: [stock_real_r7.jpg](docs/shots/laneC/stock_real_r7.jpg).
|
||||
|
||||
**→ F**: new smoke for your strict harness — `?stock=real` enter record shop + open dig → assert real
|
||||
(non-parody) sleeves; the record bin's real sleeves are cover planes with `userData.isStock` on a shared
|
||||
atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law)
|
||||
|
||||
Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). **Diagnosed** the two
|
||||
drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item —
|
||||
then batched (new `web/js/interiors/batch.js`):
|
||||
- **stock.js**: boxes are now single-material; every stock mesh tagged `userData.isStock`.
|
||||
- **batch.js** (`batchRoom`, called from interiors.js before the GLB upgrade): two-tier merge —
|
||||
room-level merge of all `isStock` across every fitting (grouped by material, world transforms baked)
|
||||
+ per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB
|
||||
upgrade can hide the fixture). `mergeGeometries` from vendored BufferGeometryUtils.
|
||||
- **glb.js**: frame-hide is now by `isStock` tag (not frame-count), so batched fixtures hide and the
|
||||
room-level stock rides on the GLB.
|
||||
|
||||
**Result** (drawSweep, all 9 types × 6 archetypes, seed 1990):
|
||||
- GLB-off worst **152** (opshop/hall), GLB-on worst **313** (opshop/hall) — book 1,245 → **189**. **≤350 ✓**.
|
||||
- Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit
|
||||
target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical.
|
||||
- **Sweep now ASSERTS ≤350** (`drawSweep()` in the test page, folded into the soak; `window.PROCITY_C.
|
||||
drawSweep` for F's harness — see LANE_C_NOTES for the hook).
|
||||
|
||||
**C2 — map the 3 orphaned fittings** (published R5, unconsumed). In `glb.js`: `glassCase→glass_case`,
|
||||
`magazineRack→magazine_rack`, and a NEW `crateStack` primitive (stack of crates) → `crate_stack`.
|
||||
Recipe placements: crateStack in record + opshop (back-room dressing), glassCase added to milkbar,
|
||||
magazineRack added to pawn (+ moved milkbar's magazineRack window→wall to dodge the door-counter).
|
||||
Also **fixed `placeNearCounter`** — it picked one random side and gave up, so counter-zone fittings
|
||||
(the glass case) rarely landed; now it tries both sides + offsets, then falls back to a free aisle spot.
|
||||
All 3 load + render planted/scaled/facing right (yaw 0). Appearance: glassCase **12/12** (toy/pawn/milkbar),
|
||||
magazineRack **12/12** (milkbar/pawn), crateStack ~11/12 (record/opshop). Contact sheet:
|
||||
[glb_orphans_r6.jpg](docs/shots/laneC/glb_orphans_r6.jpg). drawSweep still ≤350 with them on (worst 177
|
||||
GLB-on). **C3**: dig soak re-run after batching + new fittings — riffle works on batched bins, leak-free.
|
||||
|
||||
**Round 6 complete for Lane C**: C1 (batching, `35981ad`) + C2/C3 (orphans + dig re-verify) committed.
|
||||
Draw law satisfied (≤313 was the tightest; ≤177 after C2), determinism/leak/dig all green, qa strict green.
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · **F can tag v1.1**
|
||||
|
||||
Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in `glb.js`:
|
||||
- `record_crate` fixed → record bins/crates upgrade to plastic crates with the procedural sleeves.
|
||||
- `counter → 'counter_till'` (the real ~1.6 m counter-with-till). Keeper-stand pose unaffected.
|
||||
- **Double-till resolved**: cash_register attach dropped when counter_till is live (it has its own till);
|
||||
falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
|
||||
- Sweep + soak (GLB on): `0 throws / 0 path / 0 det / leak-free / worst 10.4ms`; **0 double tills**.
|
||||
Primitive path unchanged (glb.js-only edit). Contact sheet: [glb_v1_1_r5.jpg](docs/shots/laneC/glb_v1_1_r5.jpg).
|
||||
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
|
||||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). **→ F: C1 committed, v1.1 can be tagged.**
|
||||
|
||||
**C2 — `?dig=1` crate-riffle (v2, behind flag).** Ported 90sDJsim `dig.js` → `web/js/interiors/dig.js`:
|
||||
walk to a record bin, press **E**, riffle its sleeves (scroll/drag), click the front to pull, **BUY IT**
|
||||
(STUBBED — toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin
|
||||
via the `stockAdapter(shop,'sleeve')` seam (procedural fallback now — parody 90s-AU sleeves). Own scene +
|
||||
camera, procedural fwip/thunk audio, **leak-free** (per-open resources freed on close → dig soak 20×
|
||||
`leakGeo 0 / leakTex 0`). Flag-off (`?dig=0`) is **byte-identical** — `buildInterior` is untouched; the dig
|
||||
is wired only in the test page (F owns the shell seam — hook documented in LANE_C_NOTES §"input/mode hook").
|
||||
Shot: [dig_riffle_r5.jpg](docs/shots/laneC/dig_riffle_r5.jpg).
|
||||
|
||||
**C3 — interior re-measure after Lane E's bake (`c522464`).** E baked the 6 heavy hero props to ≤8k tris.
|
||||
Re-measured every interior (cache-busted to bypass the dev browser's stale HTTP cache): **record 43.6k**
|
||||
(the C3 target, ~273k pre-bake), **milkbar 38.9k** (was the flagged 468k → over-budget flag CLOSED), all
|
||||
rooms ≤107k tris. `book` is the draw-call outlier (1,245 draws from ~9 bookshelf GLBs + primitive spines) —
|
||||
tris fine, interiors render one-at-a-time so gate 3 unaffected; instancing spines is the future lever.
|
||||
Full table in [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md §C3).
|
||||
|
||||
**Round 5 complete for Lane C**: C1 (v1.1 re-map) + C2 (`?dig=1`) + C3 (re-measure) all committed.
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 4) — hero props into rooms
|
||||
|
||||
Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14
|
||||
fittings), sweep/soak with them on, screenshot each, delete the stale `_local_glb/`, measure a hero
|
||||
room. **All done.** Full report for Lane E: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md);
|
||||
contact sheet [docs/shots/laneC/glb_heroprops_r4.jpg](docs/shots/laneC/glb_heroprops_r4.jpg).
|
||||
|
||||
- **New mappings** (`glb.js`): `arcade_cabinet`→new `arcadeCabinet` primitive (video focal prop),
|
||||
`listening_booth`→`listeningCorner` (record), `drinks_fridge`→`fridge` (milkbar). All load, `glb_law`
|
||||
compliant, yaw 0 (no correction). A new **counter-top attach** (`attachCounterTop`) puts `cash_register`
|
||||
on every counter's till slot (hides the primitive till) and `milkshake_mixer` on the milk-bar benchtop —
|
||||
the counter primitive + keeper stand are untouched.
|
||||
- **Focal-prop reliability fix**: the door-counter path loop was pulling priority-4 focal props (arcade
|
||||
appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first →
|
||||
**24/24 appearance**, still 0 carves / 0 path-fails.
|
||||
- **Validation**: primitive sweep 810 builds `{throws 0, pathFail 0, detFail 0}`; GLB-on soak
|
||||
`{throws 0, path 0, det 0, leakGeo 0, leakTex 0, worst 7.3ms}`; all 5 new props confirmed in-room.
|
||||
- **Room tris measured** (task 4): milk-bar GLB-on ≈ **468k tris** (over budget) driven by `drinks_fridge`
|
||||
(77k) + `milkshake_mixer` (73k) — **reported to Lane E** as the #1 bake-pass target (not fixed, per
|
||||
integrator decision #2; interiors render one-at-a-time so gate 3 / the street budget is unaffected).
|
||||
- **Cleanup**: deleted `web/assets/_local_glb/` (R3 validation copy); test page now loads GLBs via the
|
||||
live `depot:` path (`?localdepot=1` still works for offline). `localBase` hook kept in `glb.js`.
|
||||
- **Standby (C task 5)**: when Lane E re-exports `record_crate` (broken) + a ~2 m counter-with-till, I
|
||||
re-map both. Not landed at commit time.
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 3) — GLB hero-prop visual validation
|
||||
|
||||
Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip `useGLB` on and validate Lane E's fitting GLBs in real
|
||||
rooms. Done — full report for Lane E in [docs/LANES/LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md).
|
||||
|
||||
**Verdict:** 5 of 9 fitting GLBs are good and now live behind `useGLB` (wire_shelf, clothes_rack,
|
||||
bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed
|
||||
(`record_crate` **broken — won't load**; `counter` **too long/no till** → primitive keeps the till +
|
||||
keeper stand). record bins stay primitive until Lane E re-exports the crate.
|
||||
|
||||
**Soak with `useGLB` on** (50 rooms, all types): `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
|
||||
leakTex 0 · worst build 10.5ms · 256 upgrades applied` — GLB resources dispose leak-free on room exit.
|
||||
|
||||
**glb.js changes (my file):**
|
||||
- `manifest.localBase` — load GLBs from a served local dir for offline validation (production still uses
|
||||
`depot:<file>`; forward-compatible with Lane E's local-depot mode).
|
||||
- **Real-metre scale, clamped** to the reserved footprint (was fit-to-width, which squashed tall props);
|
||||
matches `glb_law` (metres). Per-kind **yaw correction** (`KIND_TO_YAW`) for the Z-major assets
|
||||
(bookshelf/cube/work_table need +90°) per the manifest `facing` convention ("Lane B/C rotate directional props").
|
||||
- `room.glbReady` (interiors.js) — a promise that resolves when GLB swaps settle, so callers can await
|
||||
before screenshot/leak-check (Lane F can use it in the §3.4 wiring).
|
||||
- Test page (`interior_test.html`) gains a **GLB props** toggle + a `GLB props: N upgraded` HUD line.
|
||||
Local validation needs the GLBs copied in: `cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/`
|
||||
(untracked; real GLBs live on the depot). Screenshot grid: [docs/shots/laneC/glb_validation_grid.jpg](docs/shots/laneC/glb_validation_grid.jpg).
|
||||
Last updated: 2026-07-14 (b) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
|
||||
119
D-progress.md
@ -1,102 +1,5 @@
|
||||
# PROCITY-D — progress (Lane D · Citizens)
|
||||
|
||||
## Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.
|
||||
|
||||
Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the **GO** for the roster flip.
|
||||
Full go/no-go numbers + F's two-line wiring at the top of `LANE_D_NOTES.md`.
|
||||
|
||||
- **Merge validated (D1)**: all 19 peds now **1 mesh / 1 material** (was 8/2) → ~1 draw/near-rig
|
||||
(fleet 116→24 at the cap); bind+animate/no-T-pose, silhouettes+atlas materials crisp (hi-vis/suit/
|
||||
kid), impostor bake clean, identity variety + determinism preserved. GPU memory dropped too.
|
||||
- **The flip numbers (full density, perChunk 16)**: worst street view **241 draws ≤300** (was 356
|
||||
unmerged), **65,899 tris ≤200k**, 17 near-rigs; 30-chunk soak (726 builds/disposes) **leak-free, 0
|
||||
GPU delta**, heap stable. → **GO.**
|
||||
- **Full-density defaults (D2)**: `enableStream` default flipped 8→**16** (the merge affords it);
|
||||
near-cap stays 24 (now 24 draws not 168).
|
||||
- **Post-flip + escape hatch (D3)**: test page + shell now default to stream; **`?roster=v1` restores
|
||||
the fixed roster** (verified: streamMode false, determinism ✓). No-flag boot → streamMode true,
|
||||
determinism ✓. F's `web/index.html` flip wiring (invert to default-on + `?roster=v1` escape)
|
||||
documented; the in-shell no-flag-URL boot completes when F lands F1 (roster proven running in-shell
|
||||
at the shipping default via the in-shell soak above).
|
||||
|
||||
Only `web/js/citizens/sim.js` (perChunk default) + `web/citizens_test.html` (default-on + escape
|
||||
hatch) changed. Committed atomically.
|
||||
|
||||
---
|
||||
|
||||
## Round 6 (harden streamed roster toward default-on) — DONE. qa GREEN. Flag still default-off.
|
||||
|
||||
Hardened `?roster=stream` and wrote the **default-on readiness memo** (top of `LANE_D_NOTES.md`) so
|
||||
Fable + F can decide the R7 flip on evidence. In-shell (real town), stream runtime-enabled:
|
||||
|
||||
- **Soak green**: 242 chunk builds/disposes over 3 street-graph walks → **0 GPU geom/tex delta**; 16
|
||||
shop enter/exit → 0 delta; heap oscillates 50→77→54 MB (GC recovers, no leak); 188 chunk-keyed
|
||||
identities re-derive from seed; 0 console errors; flag-off byte-identical (citizen-0 = R4 golden).
|
||||
- **Hours-aware density (new)**: added `setNightLivelyChunks` + a night floor so the open-late video
|
||||
block stays lively at night (31→16) while ordinary streets go near-empty (65→6). F call-site documented.
|
||||
- **Composition with `?dig=1`**: roster is street-tier, fully inert while interior/dig is open (frame
|
||||
loop street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any
|
||||
v2 flag → all-on combo safe.
|
||||
- **Perf/budget — the one gap**: each decimated ped = 8 sub-meshes / 2 materials = ~7 draws/near-rig, so
|
||||
full-density stream (`perChunk 16`) hits **356 draws** (over the 300 street budget). Set the default to
|
||||
`perChunk 8` (**291 draws, ≤300, budget-safe today**) and documented the clean unlock — merge ped
|
||||
sub-meshes by material (8→2 → ~2 draws/rig → perChunk 16 fits at ~260) — as the single thing gating
|
||||
full-density default-on in R7 (F1-safe: v1 spawn view has ~0 near-rigs).
|
||||
|
||||
Only `web/js/citizens/sim.js` (night floor + default) + `web/citizens_test.html` (default perChunk)
|
||||
changed. Committed atomically; flag default-off; v1 path untouched.
|
||||
|
||||
---
|
||||
|
||||
## Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind `?roster=stream`. qa GREEN.
|
||||
|
||||
Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical
|
||||
when off. Full numbers + Lane F wiring in `docs/LANES/LANE_D_NOTES.md` (top).
|
||||
|
||||
- **Chunk-local identity** keyed `(seed, chunkKey, i)` → same seed + chunk → same residents regardless
|
||||
of town size / visit order; **windowed spawn/despawn** hook-driven (Lane B's `onChunkBuilt`/
|
||||
`onChunkDisposed`, sim auto-detects) with a camera-poll fallback; **global near-cap** across live
|
||||
chunks; **owner-chunk despawn** (no cross-boundary re-keying); v1 path untouched (`chunkStream` opt-in).
|
||||
- **Proven** on a big 9×9 fixture grid: (a) constant far-field density — ~58–129 citizens within 70m
|
||||
at spawn AND 150m out, vs v1's uniform ~12–15 (fixed roster spread thin); (b) deterministic — 507
|
||||
chunk-keyed identities re-derive from seed; (c) leak-free — 2 full grid walks (~380 chunk cycles) →
|
||||
**0 geom/tex delta**, pool capped 30, chunks bounded 25; (d) budget — same near-cap + 1 impostor
|
||||
draw as v1, 2.31 ms/frame; (e) **flag-off byte-identical** — shell citizen-0 signature exactly
|
||||
matches the R4 golden. Hours-aware density included (per-chunk tod thinning).
|
||||
- Only `web/js/citizens/sim.js` + `web/citizens_test.html` (bigger fixture + stream toggle/HUD/
|
||||
determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires `web/index.html`.
|
||||
Evidence: `docs/shots/laneD/r5_stream_tiers.jpg`.
|
||||
|
||||
---
|
||||
|
||||
## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.
|
||||
|
||||
All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top).
|
||||
|
||||
- **D1 — in-shell verify** ✅ In the real game (`web/index.html`): peds + keepers upgrade
|
||||
placeholder→rig; **12 enter/exit shops leak-free** (`renderer.info.memory` back to exactly
|
||||
baseline, Δ0); determinism holds across reload; `?noassets=1` fully placeholder, zero ped fetches.
|
||||
- **D2 — decimated peds** ✅ (critical path; E1 landed) Validated E's fleet: **all 19 peds ≤3k tris
|
||||
(1564–2805, avg 2450)**, rigs bind + animate (skinning/skeleton preserved, walk/idle retarget
|
||||
unchanged), **0 broken meshes** under eviction churn, silhouettes + identity variety intact,
|
||||
impostor atlas bakes clean, determinism + leak re-pass. **Gate-3 sign-off for Lane F: near fleet
|
||||
now ≈59.6k tris at the 24-cap (was ~1.2M); whole test view 88.9k (was ~1.5M).**
|
||||
- **D3 — exposure sync** ✅ Verifying it **found + fixed a real bug**: the impostor material
|
||||
self-tone-mapped (`toneMapped:false` + in-shader ACES), which is correct on my direct-canvas test
|
||||
page but **double-tone-mapped under the shell's EffectComposer/OutputPass** (three only tone-maps
|
||||
materials on the canvas path). Reworked it to a standard `toneMapped:true` material that outputs
|
||||
linear and uses three's own tone-map/colorspace chunks → tone-maps **identically to the near rigs
|
||||
on both paths**. Verified near+mid match at noon and correct night render in-shell
|
||||
(`docs/shots/laneD/r4_shell_*.jpg`). `citizens.setExposure()` is now a harmless no-op (exposure is
|
||||
global); Lane F can keep or drop the call at `web/index.html:245`.
|
||||
|
||||
Only `web/js/citizens/impostor.js` changed this round (+ my NOTES/progress/R4 shots). Committed
|
||||
atomically; `web/index.html`, `dbg.js`, and other lanes' in-flight files left untouched.
|
||||
|
||||
---
|
||||
|
||||
# (Round 1) — initial Lane D build
|
||||
|
||||
**Status: complete and verified in-browser.** 2026-07-14, by PROCITY-D (Opus 4.8).
|
||||
Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E
|
||||
(ped poly-count) is flagged below and in `docs/LANES/LANE_D_NOTES.md`.
|
||||
@ -114,28 +17,6 @@ the browser on this machine:
|
||||
0 dangling edges, 0 non-finite citizen coords, no errors. `plan.streets` is exactly the `{nodes,
|
||||
edges}` shape the sim consumes. Lane F §3.3 is a one-line construction (see below).
|
||||
|
||||
### Round 3 — support round (2026-07-14, PROCITY-D) — all 3 tasks done
|
||||
Per `docs/LANES/ROUND3_INSTRUCTIONS.md` §Lane D. Committed Lane D landed as `9eb1acc` first; then:
|
||||
|
||||
1. **Keeper GLB-rig upgrade + leak-free dispose (task 1).** Gated on F's §3.4 (keepers are still
|
||||
placeholder-only in the shell — F builds `KeeperManager` with no `fleet`). Verified **my side** of
|
||||
the contract in `citizens_test.html`: a fleet-backed keeper spawns as a **rig**, and 15 enter/exit
|
||||
spawn→greet→`remove()` cycles leave `renderer.info.memory` **exactly at baseline** (geo 140→140,
|
||||
tex 175→175) → leak-free. Enablement for F is one arg (`fleet`); documented in LANE_D_NOTES.
|
||||
2. **`ImpostorLayer.setExposure()` wired to day/night (task 2).** Added a clean `CitizenSim.setExposure(e)`
|
||||
passthrough (sim.js) that also **survives the placeholder→rig atlas re-bake** (stored + re-applied).
|
||||
Confirmed the shell *does* animate `renderer.toneMappingExposure` per segment (`lighting.js`), so
|
||||
this is a real fix, not defensive. Verified: uniform tracks 0.55↔2.3 and persists across the upgrade.
|
||||
Left F the exact one-line call site (`index.html:225`, after `setTimeOfDay`) in LANE_D_NOTES — it's
|
||||
an F-owned seam edit, so I documented rather than edited it.
|
||||
3. **Chunk-streamed roster design note (task 3).** Written in LANE_D_NOTES ("Chunk-streamed roster —
|
||||
v1.5 design note"): chunk-local identity keying, poll- vs hook-driven windowing (with the `chunks.js`
|
||||
reality), global near-cap across live chunks, ownership/hand-off, budget, and an opt-in migration that
|
||||
leaves v1 untouched. **Design only, no implementation** as instructed.
|
||||
|
||||
Post-edit re-verify: no console errors, determinism ✓ (200 match seed), near capped 24, mixer 0.3 ms.
|
||||
Round-3 code touches **only sim.js** (my file); the rest is docs. No sibling files edited.
|
||||
|
||||
---
|
||||
|
||||
## What I built (owns `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*`)
|
||||
|
||||
117
E-progress.md
@ -1,119 +1,7 @@
|
||||
# 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` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in
|
||||
other lanes' files was touched.*
|
||||
|
||||
## Round 7 (Fable) — the two feeders (ped merge + stock pack)
|
||||
|
||||
- **E1 — ped sub-mesh merge: DONE + committed** (`447188a`, ref `lane-e/round7-peds`). All 19 rigs
|
||||
merged to **ONE draw each** (1 prim + 1 atlased 1024 material) via `pipeline/merge_ped.py` (Blender
|
||||
join → UV-pack → EMIT-bake all materials into one atlas). Skinning + 65-joint skeleton preserved,
|
||||
`walk`/`idle` untouched. **Fleet 92→19 draws; near-cap 116→24 (79% cut).** Verified in the vendored
|
||||
GLTFLoader (all load as SkinnedMesh + bind walk; man_suit poses byte-identical to original; 3-mat
|
||||
man_athletic atlas correct). Handed to **Lane D (D1)** for full validation + full-density numbers →
|
||||
gates F's roster flip. qa `--strict` GREEN 5/5.
|
||||
- **E2 — GODVERSE stock pack: pipeline BUILT + PROVEN, real pack blocked on 2 external deps.**
|
||||
`pipeline/build_stock_pack.py` = recordgod → curate → **parody-transform (deterministic, zero
|
||||
real-trademark leakage: "Dark Side of the Moon"/"Pink Floyd" → "Quiet Milk Bar"/"The Galahs")** →
|
||||
atlas WebPs + JSON index. Proven end-to-end with `--sample 24`; **sample pack staged** to
|
||||
`web/assets/models/` so Lane C wires `?stock=real` against the real index format now. **Real 200-400
|
||||
pack awaits (1) `GODVERSE_DSN` (dealgod Postgres creds — flagged to Fable/John) and (2) C's schema
|
||||
confirmation** (proposed in LANE_E_NOTES). One command (`--from-db --count 300`) once they land.
|
||||
- **E3 — on-call** (B rain-streak/puddle texture): none requested. No fal spend (balance ~$21.36).
|
||||
|
||||
## Round 6 (Fable) — non-destructive pipeline + on-call
|
||||
|
||||
- **E1 — publish.py non-destructive + provenance-drift gate: DONE + committed.**
|
||||
- `publish.py` now **merges** into `_published.json` (read existing → union → sorted write),
|
||||
never replaces — fixes the R5 `--only` clobber (record dropped 27→1). **Proven:** a real no-op
|
||||
re-publish of `record_crate` kept the record at **27 (was 27)**, not 1.
|
||||
- Post-publish + a new `--verify` mode **drift-check** the record against the live depot
|
||||
(`/api/list` filtered to `procity_*`) and warn on either direction. `--verify` confirms 27 == 27.
|
||||
- `validate_manifest.py` gains a **provenance-drift gate**: every manifest `procity_*` GLB must be
|
||||
in `_published.json`, else QA fails. **Proven:** simulating the R5 clobber (record→1) makes the
|
||||
validator exit 1 with per-file drift errors; restored → green. So the clobber class fails loudly.
|
||||
- **Record verified**: `_published.json` (27) is byte-exact vs the live depot — Fable's R5
|
||||
regeneration was correct, no fix needed.
|
||||
- `qa.sh --strict` GREEN (5 gates incl. F1's new v2-flags harness).
|
||||
- **On-call for B/C** (task 3): available for bus_shelter/bin scale-origin tweaks (B) and
|
||||
glass_case/magazine_rack in-room issues (C). Broken-prop fal re-gen pre-authorized ≤2 attempts
|
||||
(decision #5) — none needed yet. No speculative generation.
|
||||
|
||||
## Round 5 (Fable) — hero-prop bake + fal fallbacks + depot growth
|
||||
|
||||
- **E1 — hero-prop bake-to-lowpoly: DONE + committed.** New `pipeline/bake_lowpoly.py` (Blender
|
||||
headless): voxel-remesh → decimate → **Cycles base-colour bake** onto fresh UVs (R3's lesson:
|
||||
simplify-only floors on shell-soup; the re-bake is the point). All **6 heavy TRELLIS props ≤8k
|
||||
tris** (drinks_fridge 77k→8k, milkshake_mixer 73k→8k, listening_booth 54k→8k, counter_till
|
||||
32k→8k, novelty_record 26k→8k, arcade_cabinet 25k→8k). **Fleet 288k→48k tris, 18M→5.2M.** Each
|
||||
eyeballed (`docs/shots/laneE/props_baked.png` — fridge needed a coarser voxel re-bake, rest clean)
|
||||
+ verified loading in the vendored GLTFLoader. Re-published; `validate --depot` 0 err. Originals
|
||||
gitignored in `pipeline/.props_orig/`. → Lane C re-measures the record interior (was ~273k).
|
||||
- **E3 — fal.ai fallbacks: DONE.** glass_case + bus_shelter + magazine_rack via fal Hunyuan3D v2
|
||||
(`pipeline/gen_props_fal.py`, key from MODELBEAST settings). Frosted/opaque concepts (`flux_local`)
|
||||
gave the reconstructor a surface — all 3 succeeded where TRELLIS failed. Baked to ≤8k (EMIT albedo
|
||||
bake — Hunyuan materials need base-colour→emission or DIFFUSE bakes near-black), verified loading,
|
||||
published, `validate --depot` 0 err. **Total fal spend ~$0.64** (glass 1 att, bus 2 att, mag 1
|
||||
att × ~$0.16), well under the <$1 estimate; guardrail held. Costs logged in AUDIT R5. Last two
|
||||
primitive-only fittings (glass_case, magazine_rack) retired; new street `bus_shelter`. Manifest
|
||||
now 17 fittings + 9 furniture (26 GLBs). Contact sheet `docs/shots/laneE/props_fal.png`.
|
||||
- **E2 — depot growth: DONE (light, consumer-driven).** The v2 features are procedural (dig uses
|
||||
generated sleeves, B's window shader + D's roster need no GLBs), so per Fable "no unconsumed
|
||||
entries" I added two clear-consumer props: `street/bin` (council-bin → Lane B) + `fit/crate_stack`
|
||||
(milk-crate-stack → Lane C floor piles/dig). Both 3k tris, re-normalized + published, verified
|
||||
loading, `validate --depot` 0 err. Also **resolved Lane B's B3 ask** — novelty_record 26.5k→8k
|
||||
via the E1 bake. Manifest now **18 fittings + 10 furniture (28 GLBs)**. Further growth on request.
|
||||
|
||||
## Round 4 (Fable) — ped decimation (gate-3 critical path) + asset fixes
|
||||
|
||||
- **E1 — ped decimation: DONE + committed** (`518678d`, ref `lane-e/round4-peds`). The 19 ped rigs
|
||||
in `web/models/peds/` were **924k tris total** (31k–56k each) — the gate-3 blocker. Decimated to
|
||||
**≤3k each** (1564–2805; fleet **924k→46.5k**, ~2.45k avg) via `gltf-transform weld → simplify
|
||||
--ratio 0.05 --error 0.12` (meshoptimizer — works on these manifold skinned meshes, unlike R3
|
||||
TRELLIS shell-soup). **Skinning + skeleton fully preserved** (65 joints intact, `walk`/`idle`
|
||||
untouched); contact sheet `docs/shots/laneE/peds_decimated.png` clean at street distance. Originals
|
||||
gitignored in `pipeline/.peds_orig/`; per-ped in `pipeline/_peds_decim.json`. → **Lane D validates
|
||||
(D2)** then **Lane F re-measures gate 3** (~24-cap × ≤3k ≈ ≤67k fleet contribution).
|
||||
- **E2a — `record_crate` FIXED, verified + published.** Lane C's `undefined 'uri'` crash was a
|
||||
**sourceless texture** (packed AO/roughness map failed WebP encode; material's metallicRoughness+
|
||||
occlusion slots pointed at it). New tool `pipeline/fix_glb_textures.py` strips those slots (three.js
|
||||
loads textures lazily → no crash). **Verified it loads in the real vendored `GLTFLoader`** (meshes=1,
|
||||
tris=5000). **Re-published to the depot** (the fixed version is live, tex refs all-sourced).
|
||||
- **E2b — counter-with-till (~2 m): LIVE.** New `counter_till` fitting
|
||||
(`procity_fit_counter_till_01.glb`, **1.61 × 0.64 m, 1.15 m tall**, 32k tris) — timber counter +
|
||||
cabinet doors + period till. Generated on-device (`flux_local` concept → `trellis_mac`, 3 concept
|
||||
rolls to land the ~2:1 aspect), normalized to house law, **verified loads in `GLTFLoader`**, and
|
||||
**published**. Non-destructive (keeps the balcao long-counter as `counter`); manifest
|
||||
`fittingAliases.counter` → `counter_till`; Lane C re-maps kind `counter` → `counter_till` in `glb.js`.
|
||||
- **Publish: DONE** (John approved). Both re-exports live; **manifest `validate --depot` GREEN
|
||||
(0 err, 23 GLBs live)**. `publish.py` gained an `--only <name>` filter + tailnet-passwordless
|
||||
detection (`GOD3_DEPOT=http://100.94.195.115:8788`, decision #4).
|
||||
- **Not doing** (per Fable decisions): fal.ai spend (glass-case/bus-shelter parked); hero-prop
|
||||
bake-to-lowpoly.
|
||||
|
||||
## Round 3 (Fable) — hero props on MODELBEAST + local-depot mode
|
||||
|
||||
- **Hero props, free & on-device** (`pipeline/gen_props.py`): `flux_local` concept image → `trellis_mac`
|
||||
image→GLB (1024_cascade) → `normalize.py` → thumbs → manifest. **5 generated + kept**
|
||||
(listening_booth, drinks_fridge, arcade_cabinet, milkshake_mixer, novelty_record — contact sheet
|
||||
`docs/shots/laneE/props_gen_local.png`), **cash_register reused** from the depot
|
||||
(`vintage-cash-register.glb`). 3 didn't make it: **magazine_rack** → in-engine primitive (112k-tri
|
||||
wire, the thin-wire gotcha); **glass_case + bus_shelter** → **fal.ai fallback list** (TRELLIS can't
|
||||
do transparent glass; sf3d gives a featureless blob) — gated on John, ≤$2.70, both have primitive
|
||||
fallbacks. Every kept thumbnail eyeballed; no legible branding (see AUDIT provenance).
|
||||
- **Local-depot mode** (`core/loaders.js` `?localdepot=1` + `pipeline/stage_local_depot.py`): resolves
|
||||
`depot:<file>` to `web/assets/models/` served by the dev server, so Lanes B/C/F validate GLB loading
|
||||
**with no network / before publish**. Verified live in-browser (loads a `depot:` GLB, CDN path intact).
|
||||
- **Manifest**: 14 fittings + 8 furniture (5 hero + cash_register added); `validate_manifest.py`
|
||||
green (0 err). Provenance recorded in `AUDIT.md` (props are 100% synthetic/local — cleanest in the set).
|
||||
- **Known tradeoff**: TRELLIS meshes are dense shell-soup — decimate floors 25–77k tris (heavier than
|
||||
the ≤5k library law). Accepted for focal/optional/LOD-able props; texture-bake-to-lowpoly is a follow-up.
|
||||
- **Published (self-serve, John's go 2026-07-14)**: the 5 prop GLBs + thumbs pushed to the depot via
|
||||
the passwordless tailnet path (decision #4); all 22 GLBs re-verified live. **`qa.sh --strict` GREEN**
|
||||
(4/4 gates; manifest validator 0 err / 0 warn with the props live). Provenance was recorded first.
|
||||
- **Not committed**: this round is **held for Fable review** (per John) — all Lane E round-3 changes are
|
||||
in the working tree, uncommitted. GLB binaries stay git-ignored/staged; the depot has them.
|
||||
Reviewed against `docs/LANES/LANE_E_ASSETS.md` acceptance. Nothing in other lanes' files was touched.*
|
||||
|
||||
## Status at a glance
|
||||
|
||||
@ -123,8 +11,7 @@ other lanes' files was touched.*
|
||||
| 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 | round-3 `gen_props.py` (MODELBEAST, free) → **5 local** + cash-register reuse | ✅ **DONE local** (see Round 3 above; 2→fal, 1→primitive) |
|
||||
| 6 Local-depot | `loaders.js ?localdepot=1` + `stage_local_depot.py` | ✅ **DONE, verified in-browser** |
|
||||
| 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**
|
||||
|
||||
@ -4,101 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 7 (the flip round → v2.0-beta) — IN PROGRESS
|
||||
|
||||
Critical path resolved: E1 ped-merge (`447188a`), E2 stock pack (`0c12dac`), **D's GO** (`43edabf`),
|
||||
C1 `?stock=real` (`3724a65`) all landed. F executes flip → strict harness → tag.
|
||||
|
||||
| task | status |
|
||||
|---|---|
|
||||
| **F1 — roster flip (prime-law amendment)** | ✅ **DONE + verified.** `index.html`: streamed roster is now the **default** (`chunkStream:{radius:2}` per D's spec); `?roster=v1` = escape hatch (fixed roster); `?roster=stream` still accepted (no-op). Hours-aware `setNightLivelyChunks` on the open-late block. **Same commit** re-pins the flags-off baseline: **old (163 draws/19k tris, roster-off) → new (164 draws/~66k tris, roster-on)** — draws barely move (E1's merge = ~1 draw/rig, the whole point), tris = the crowd (D worst-view 65,899; drift-WARN now draws-only, tris under the hard cap). **Plan goldens UNCHANGED (`0x3fa36874`).** Also fixed the harness's roster probe (was reading a nonexistent prop → blind) + flipped the assertion to require streamMode ON + added an escape-hatch check. Verified: default boot streamMode:true (146–150 active), `?roster=v1` streamMode:false (96), both render, 0 errors, ≤300 draws, harness GREEN. |
|
||||
| **F2 — harness to strict + new-flag smokes** | ✅ **DONE + verified.** `qa.sh` flags gate `warn_gate → run_gate` — the harness (flags-off regression + 4 existing flags + all-on combo) is now **STRICT** (a FAIL fails qa). Wired **`?stock=real`** (F owned the seam): shell preloads the record pack + passes `stockReal`; `interior_mode` builds `makeStockAdapter(getStockPack(shop.type))` and passes it to **both** `buildInterior` (static crate sleeves) and `dig.open` (riffle) — fail-soft to parody if the pack's absent. Added `stock=real` + `weather=1` smokes at **warn-level** (decision #4). Verified: crates show **real GODVERSE covers** (smoke: 7 real atlas sleeve meshes, non-parody batched); weather auto-skips (Lane B not landed); `qa.sh --strict` GREEN (5 gates). |
|
||||
| **F3 — tag v2.0-beta** | ✅ **TAGGED** (no-hostage rule). Criteria met: roster default-on + escape hatch ✓, harness strict+green ✓, `?stock=real` real sleeves ✓, qa strict green ✓. |
|
||||
| **⚠ schedule-harness flag** | **B (`?weather=1`) and A (osm parity + 2nd town) did NOT land round-7** — no commits, tree clean. Both are additive (not headline keys); tagged v2.0-beta without them per the no-hostage rule. Weather smoke is in the harness ready to graduate the moment B ships. Flagging for Fable: two lanes slipped this round. |
|
||||
|
||||
---
|
||||
|
||||
## Round 6 (finish v2 wiring + pay review debts → v2.0-alpha) — DONE ✅ (v2.0-alpha tagged)
|
||||
|
||||
Sequencing (Fable): **F1 enforcement harness lands FIRST** (protects the round), then F re-smokes as
|
||||
lanes land, adds C's ≤350-draw interior assertion when C1 lands, extends the determinism gate if A's
|
||||
`?plansrc` lands, and tags `v2.0-alpha` when all-green (3 flags acceptable if A no-shows).
|
||||
|
||||
| task | status |
|
||||
|---|---|
|
||||
| **F1 enforcement harness** | ✅ **DONE + wired.** `tools/flags_check.py` (self-contained server, headless, ~90s): **flags-off regression** (golden hash `0x3fa36874` + subsystems-inactive + ≤300/≤200k budget + ±40% snapshot + 0 errors) · **per-flag smokes** (winmap/dig/roster: cross ≥2 chunks + enter interior; dig opens+closes a riffle) · **all-on combo** (flag-composition law, decision #4). Wired into `qa.sh` as a **warn-level gate** (`--no-flags` skips fast; auto-skips without the Playwright venv). Current: **all smokes GREEN**, regression clean. |
|
||||
| **F2 plansrc** | ✅ **DONE + verified.** A landed the full seam (`52eb109`). Wired the shell bootstrap (`index.html`, F-owned): `?plansrc=osm` → `citygen.generatePlanFor(seed,'osm')` → real-data town **"Melbourne", 95 shops** (synthetic default = "Boolarra Heads" unchanged). Pinned **both goldens** in `scaffold_check.mjs`: synthetic `0x3fa36874`, **osm `0x34cfdec0`** (deterministic, 2 runs). Harness auto-detects plansrc + smokes it (enters OSM shop "Mui Karaoke Rooms") + includes it in the all-on combo. Also restored `binUnderAim`'s **nearest-bin fallback** (matches C's test page) so E riffles a nearby bin without perfect aim / before the bin GLB finishes loading. Synthetic golden frozen (selfcheck 1362/1362). |
|
||||
| **≤350 interior draw assertion** | ✅ **DONE.** C1 batching landed (`35981ad`). Added an interior draw-budget sweep to `flags_check.py` (enter all 9 shop types, assert ≤350 draws/room, dig on so it also confirms batching kept riffling). **Caught a real bug in my own `interior_mode.js`**: it rendered the interior without `renderer.info.reset()` while `autoReset=false`, so draws *accumulated* per frame (inflated 864/644/…). Fixed (reset per interior frame, both dig + walk render). True per-frame result: **all 9 room types ≤350 (worst dept 96)** — C's batching verified. |
|
||||
| **F3 tag `v2.0-alpha`** | ⏳ all landed flags smoke-green + regression green + qa strict green → tag (3 flags OK if A no-shows). |
|
||||
|
||||
Base: v1.1 (`3a831fe`), HEAD `40fefd9`. `qa.sh --strict` GREEN.
|
||||
|
||||
---
|
||||
|
||||
## Round 5 (v1.1 close-out + v2 foundations) — DONE ✅ (v1.1 tagged; ?dig=1 wired)
|
||||
|
||||
Lane F role: **v1.1 watch** + v2 flag-seam wiring + QA-harness-for-v2 + shared-tree marshal.
|
||||
Full tracking in [`LANE_F_NOTES §10`](docs/LANES/LANE_F_NOTES.md).
|
||||
|
||||
| task | status |
|
||||
|---|---|
|
||||
| **fal un-park check** | ✅ Verified already done — `039c179` un-parked fal in Lane E's brief (decision #4 + Lane E task 3: glass-case + bus-shelter + optional magazine-rack, ≤2 attempts, costs logged in AUDIT.md). **Nothing for F to change.** |
|
||||
| **archive R3/R4 instruction claims** (F4) | ✅ Added "SUPERSEDED by R5" banner to `ROUND3_INSTRUCTIONS.md` + `ROUND4_INSTRUCTIONS.md`. |
|
||||
| **v2 flags table** (F4) | ✅ Scaffolded in LANE_F_NOTES §10 (`?plansrc=osm` A, `?winmap=1` B, `?dig=1` C, `?roster=stream` D) — all pending owning-lane specs. |
|
||||
| **F1 tag v1.1** | ✅ **TAGGED.** C1 (`aa9c140`: record_crate fixed + counter_till re-map) + E1 (`c522464`: 6 hero props baked 288k→48k, ≤8k each, live on depot) landed. Verified: depot serves baked props (counter_till 7999, drinks_fridge 8000, listening_booth 7986 tris ≤8k), C's `glb.js` maps them, record/opshop interiors render the **counter_till GLB** with keeper stand pose working + no double till, bins upgraded. Re-captured the 2 interior tour shots (fresh chromium → baked props). qa GREEN. `git tag v1.1` (local). |
|
||||
| **F2 wire `?dig=1`** | ✅ **DONE + verified.** C shipped `dig.js` + the flag but left the in-game input/mode hook to F (LANE_C_NOTES §C2). Wired the consumer pattern into `interior_mode.js` (F-owned): aim at a `userData.kind==='bin'` fitting + press **E** → `createDig`/`dig.open` (per-bin seed via `binSeed(shop.seed,pos)`); the dig owns the frame (render `dig.scene`, no interior walk); WALK OUT / Esc closes → back to the room. Shell seam (`index.html`): `?dig=1` → `dig:` opt; `procity:digOpen` releases pointer-lock (riffle is cursor-driven); unlock→leaveShop guard reads `interiorMode.digActive` so opening a bin ≠ leaving. **Fresh-chromium smoke:** E opens riffle (seeded sleeves + prices render), WALK OUT closes to interior, **leak-free** (steady-state geo/tex flat over 3 cycles), **0 console errors**. Flag off ⇒ no listener/no dig ⇒ byte-identical to v1. |
|
||||
| **F2 — other v2 seams** | B `?winmap` / D `?roster=stream` are self-contained (no shell input hook needed); smoked clean (boot, 0 errors, budget OK). A `?plansrc` not taken. |
|
||||
| **F3 v2 QA harness** | ⏳ determinism gate (first-interior-hash per seed) · flags-off regression check (v2-prime-law enforcement) · v2 smoke → qa.sh warn-gate. Build as flags land. |
|
||||
|
||||
Base state: v1.0 (`2698b40`), `qa.sh --strict` GREEN 4/4, tree clean.
|
||||
|
||||
---
|
||||
|
||||
## Round 4 (v1-tag round) — DONE ✅ (all 6 gates green; v1.0 tagged)
|
||||
|
||||
Executed Fable's `ROUND4_INSTRUCTIONS.md` Lane F tasks. All lanes committed R4; a 6-auditor
|
||||
adversarial v1-readiness workflow returned **GO**; `v1.0` tagged locally on the F finalization commit.
|
||||
|
||||
| task | status |
|
||||
|---|---|
|
||||
| **F1 setExposure** | ✅ Wired (`9416d0a`) **then correctly removed** (`aad9baa`): D3 (`a5e4b64`) made the impostor `toneMapped:true`, so `citizens.setExposure()` became a no-op tone-mapped globally via `renderer.toneMappingExposure`. Removed only *after* D committed (removing earlier would regress night brightness). Final state: no dead call. |
|
||||
| **F2 storeys-checker** | ✅ `permittedMax = max>=2 ? min(max+1,3) : max` (Lane A's rule, `9416d0a`). Consistency **0 warnings** across 6 seeds; single-storey-type-at-2 still trips. **A confirmed** (`5b8c806`). |
|
||||
| **F3 gate-3 budget** | ✅ **PASS.** E1 decimated peds (`518678d`, 924k→46k, ≤3k each) + **D2 sign-off** (`a5e4b64`, fleet ≈59.6k at 24-cap). Official measure: busiest node, pop 200, midday, 12-yaw peak → **draws 138 (≤300) · tris ~31k (≤200k)**. Baseline 372k. |
|
||||
| **F4 gate-6 shots** | ✅ **PASS.** Against committed B2 (`6323d58`, `dbg.js` letterbox+poses): `shots.py v1_tour` → **10 full-frame 1280×720, no letterbox**; §3.5 **night closed-facade** (CLOSED plates + lit openLate video "VIDEO REGAL") + real **record/opshop interiors** (keeper + hero props). Fixed `shots.py` so interior shots capture via `enterShop(id)` (were falling back to a street bookmark). |
|
||||
| **F5 tag v1.0** | ✅ **TAGGED** on the F finalization commit (gate table all-green + shots committed). Local only — John pushes. |
|
||||
| **audit** | 6-auditor v1-readiness workflow: 5 GREEN (gates 1/3/5/6 + release hygiene) + 1 procedural "land finalization commit first" → resolved. GO. |
|
||||
|
||||
---
|
||||
|
||||
## Round 3 (lead lane) — DONE, verified live
|
||||
|
||||
Executed Fable's `ROUND3_INSTRUCTIONS.md` Lane F tasks. All wiring verified in-browser on seed 20261990.
|
||||
|
||||
| task | result |
|
||||
|---|---|
|
||||
| **§3.5 hours / closed shops** | `isOpen()` gate on `enterShop`. Midday: open. **Night (22:00): all closed except the openLate video rental** (Video Regal [11,23]) — verified toy shop blocked, video enters. |
|
||||
| **§3.4 `?noassets`** | **Zero network** (Perf API: 0 GLB/depot/manifest/ped-model requests); 100% primitive, legible. |
|
||||
| **§3.4 interior GLB** | `useGLB` → fitting GLBs load from the **3GOD depot** (bookshop shelves, +8 meshes, visually confirmed). |
|
||||
| **§3.4 rig fleet** | 19 ped GLBs → peds **and** keepers upgrade placeholder→**GLB rigs**; **leak-free** (shared-cache proven, cycle2/3 flat). |
|
||||
| **`--depot` on qa.sh** | `validate_manifest.py --depot` = 0 err (16 GLBs live). **qa.sh GREEN 4/4.** |
|
||||
| **Gate 2 soak** | **PASS** both modes — assets: 20 interiors leak-free (+GLB-cache warmup), heap 98MB stable; noassets: 37 chunks, heap 15MB. 0 console errors. |
|
||||
| **Gate 4 asset-free** | **PASS.** |
|
||||
| **Gate 6 shots** | `docs/shots/v1_tour/` (10 shots) + contact sheet captured; letterbox + 3 fallback bookmarks are Lane B dbg.js. |
|
||||
|
||||
**Findings → other lanes** (details in [`LANE_F_NOTES §9`](docs/LANES/LANE_F_NOTES.md)):
|
||||
- **D/E:** rig ped GLBs are **tri-heavy** (~45k each) → gate 3 tris **~279k > 200k** budget (draws fine ~200/300).
|
||||
Decimate the ped GLBs; F holds `?pop=140` until then.
|
||||
- **B:** add 3 shots bookmark poses; fix the headless composer letterbox in `DBG.shot()`; render the §3.5
|
||||
closed-facade visual (dark windows + CLOSED plate; predicate `window.PROCITY.isOpen`).
|
||||
- Fixed **5 F-owned harness bugs** so `shots.py`/`soak.py` actually run (DBG async-poll, `DBG.plan`→`PROCITY.plan`,
|
||||
`enterShop` bare-id, `shop.lot` is an id, midday-pin + GLB-cache warmup + budget→warning).
|
||||
|
||||
**Git:** working tree synced to canonical `main` (862bbcd) mid-session — my R2 work is committed as `aa4ed4f`.
|
||||
R3 edits below are staged by pathspec (see Housekeeping). Nothing pushed.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (round 2 — for context)
|
||||
## TL;DR
|
||||
|
||||
All five lanes (A–E) landed; F stood up the QA harness, **adversarially reviewed and fixed its own
|
||||
tooling**, then wired the lanes into **a walkable, living town** — interiors, shopkeepers, and street
|
||||
|
||||
@ -1,56 +1,9 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (1362/1362); rounds 2–6 closed** (see below).
|
||||
`qa.sh --strict` GREEN.*
|
||||
*Status: **all deliverables landed; self-check all-green (1283/1283); round-2 cross-lane requests from
|
||||
B/E/F closed** (see bottom). Awaiting Fable review.*
|
||||
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## Round 6 (2026-07-14) — the second plan source (`?plansrc=osm`), FULLY LANDED
|
||||
|
||||
The carried-over OSM plan-source seam (no-showed rounds 4–5) is **done** — not the escape-hatch
|
||||
partial, the whole thing, invariants green. A second CityPlan producer behind the same contract, so
|
||||
Lanes B–F consume it unchanged.
|
||||
|
||||
- **`osm_fixture.js`** — 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops
|
||||
(book 36, toy 14, opshop 13, record 12, video 11, pawn 9), extracted deterministically from
|
||||
thriftgod's `city_source.json` Overpass cache. Checked in, imported as a module → **zero network**.
|
||||
- **`plan_osm.js`** — `generatePlanOSM(seed)`: projects real lat/lon → metres, bins shops into
|
||||
latitude-band avenues off a spine (real row + real east–west order preserved), marches them into
|
||||
a valid, non-overlapping CityPlan, centres on origin, reports its true 362×486 bbox. Real shop
|
||||
names kept ("Central Catholic Bookshop"). Exactly-one-openLate-video rule matches synthetic.
|
||||
- **`index.js`** — `generatePlanFor(seed, source)` selector; default `'synthetic'` is byte-identical
|
||||
(golden `0x3fa36874` untouched). `generatePlanOSM` exported for F.
|
||||
- **`selfcheck.js`** — parameterized by source: OSM runs the full structural invariant suite
|
||||
(frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own golden
|
||||
**`0x34cfdec0`**; synthetic-only brief checks (market/arcade/dept/milkbar) deliberately skipped.
|
||||
- **`map.html`** — `?plansrc=osm` renders it; screenshot `docs/shots/laneA/osm-melbourne.png`.
|
||||
- Seam + goldens + shape-differences documented for F's determinism gate in `LANE_A_NOTES.md` (R6).
|
||||
|
||||
**Verified**: 95/95 shops placed, 0 bad frontEdges / 0 overlaps / 0 backwards / full chunk coverage,
|
||||
deterministic, synthetic path byte-identical. Self-check **ALL GREEN 1362/1362**; `qa.sh --strict` GREEN.
|
||||
Honest cut (documented): street geometry is regularized (real row+order drive it, exact projected
|
||||
metres do not) and shop-name parody is deferred.
|
||||
|
||||
## Round 4 (2026-07-14) — v1-tag round: 2 confirmations, 1 hardening fix
|
||||
|
||||
**A1 — storeys checker (post-F2): CONFIRMED.** `tools/qa/consistency_check.mjs` `permittedMax` scoping
|
||||
(Lane F's F2, matching my R3 spec) gives **0 storeys warnings** across 7 seeds; `qa.sh --strict` GREEN
|
||||
(4/4). False-negative test: injected a real `video@2` into a scratch copy of the checker's input →
|
||||
caught on every seed. No benign-anchor false positives, no real-drift false negatives.
|
||||
|
||||
**A2 — openLate contract: AUDITED + HARDENED.** F's night gate `hours[1] >= 22` was matching 4–15
|
||||
shops/seed (regular videos jittered from base-21 to 22), not the single landmark it assumes. Fixed in
|
||||
plan.js so `hours[1] >= 22` ⟺ `openLate` (exactly one, always the video rental): regular shops close
|
||||
by 21:00; only the deterministically-chosen video landmark reaches ≥22 (`LATE_HOUR`). This also makes
|
||||
the generator finally match what `web/index.html:159-161` and `soak.py:68` already assumed
|
||||
("at night only the openLate shop is open"). Added selfcheck assertions; verified across 15 seeds.
|
||||
|
||||
**⚠ GOLDEN.hash: `0x098eec2b` → `0x3fa36874`** — intended output change from the A2 fix (not drift;
|
||||
F2 is tooling-only and didn't touch generation). Same lots/shops/types/names/skins; only a few videos'
|
||||
close hour moved 22→21. Details for Fable in `LANE_A_NOTES.md` (round 4).
|
||||
|
||||
**Self-check:** `✓ ALL GREEN — 1301/1301` (was 1283; +18 are the new openLate exactly-one / field⟺threshold
|
||||
/ is-video assertions). Seed 20261990 → "Boolarra Heads": 681 lots, 493 shops, unchanged.
|
||||
|
||||
> **⚠ Fable — commit-label mixup (no action needed on the code):** my round-2 changes are committed
|
||||
> and correct, but a shared-working-tree race landed them under Lane C's message —
|
||||
> commit **`4235060` "Lane C: keeper stand pose…" actually contains only my 5 Lane A files** (a
|
||||
@ -214,34 +167,3 @@ stall facades now appear in the market square.
|
||||
single reference for Lane C/E when modelling facades. No code change; it works.
|
||||
- **Still-open design questions from round 1** (retail density ~493/core vs Lane B's draw budget; true
|
||||
closed residential ring) remain parked for your call — both are one-constant tunes, not rework.
|
||||
|
||||
## Round 3 — verification round (2026-07-14, per ROUND3_INSTRUCTIONS "Lane A — on-call + one verification")
|
||||
|
||||
Small round as briefed. All three tasks done; `bash tools/qa.sh --strict` **GREEN** (4/4 gates,
|
||||
selfcheck 1283/1283, manifest 0 errors).
|
||||
|
||||
**1. F finding #5 — "shops with storeys outside the registry type range" → verified STALE / CLOSED.**
|
||||
Scanned every shop across 8 seeds against the *correct* permitted range. Result: **zero violations.**
|
||||
`video`/`milkbar`/`stall` (registry `[1,1]`) are observed at 1 storey only (e.g. 412 video shops, none
|
||||
at 2 — so F's specific "video at 2" case does not occur). The only shops above their registry `max`
|
||||
are the tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn` `[1,2]`, `dept` `[2,3]`) reaching 3
|
||||
via the intentional **corner anchor** — exactly the CITY_SPEC "occasional 3-storey corner anchor". My
|
||||
round-1 `cornerBoost` gate (only boosts registry max ≥ 2) already prevents the reported bug.
|
||||
- **This is the source of `qa.sh`'s 5 consistency warnings** ("N shops have storeys outside their
|
||||
registry range … ask Lane A"). **They are benign** — all intended corner anchors, not plan↔registry
|
||||
drift. *Lane F: you can silence them by scoping the check to `permittedMax = max≥2 ? min(max+1,3) : max`
|
||||
instead of the strict registry `max`.*
|
||||
- **Hardened anyway** (the finding's real lesson): the selfcheck's storeys assertion was too lax
|
||||
(`<= Math.max(mx,3)` would have passed a video at 3). Tightened to the exact permitted range, so a
|
||||
single-storey type exceeding 1 (or any type past `min(max+1,3)`) now fails loudly. Proved non-vacuous
|
||||
(rejects `video@2`, `record@4`, `dept@1`; allows `record@3`). Documented the `storeys` rule in the
|
||||
CITY_SPEC schema so the intended-vs-drift question can't recur.
|
||||
|
||||
**2. Golden fingerprint confirmed.** Committed `GOLDEN.hash = 0x098eec2b` matches the current selfcheck
|
||||
fingerprint on main. No output drift. (selfcheck.js assertion tightening doesn't touch the plan, so the
|
||||
hash is unchanged.)
|
||||
|
||||
**3. Hours / `openLate` contract — ready for its first real consumer (Lane F §3.5).** No changes needed:
|
||||
`hours=[open,close]` (24h ints, `0≤open<close≤23`), **exactly one `openLate:true` landmark per town**
|
||||
(closes ≥22:00, video/milkbar, never a stall). ROUND3_INSTRUCTIONS §Lane F restates it correctly, so
|
||||
F's night-gate wiring can consume it as-is. Full contract in CITY_SPEC schema + the round-2 notes above.
|
||||
|
||||
@ -73,10 +73,6 @@ Schema v1 (Lane A owns it; extend, don't break):
|
||||
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
|
||||
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
|
||||
// storeys: integer in [registryMin, permittedMax] where permittedMax = registryMax≥2 ? min(registryMax+1,3)
|
||||
// : registryMax. The "+1 (cap 3)" is the CITY_SPEC "occasional 3-storey corner anchor" — it fires ONLY
|
||||
// for tall-capable types (registry max ≥ 2); single-storey types (video/milkbar/stall, [1,1]) are NEVER
|
||||
// boosted. So `storeys > registryMax` on a corner anchor is EXPECTED, not plan↔registry drift.
|
||||
// openLate: present and `true` on EXACTLY ONE shop per town — the designated late-night landmark
|
||||
// (closes ≥ 22:00; a naturally-late type, video rental or milk bar, never a market stall). Absent
|
||||
// on every other shop. Lane B lights it after dark; Lane F's night gate keys off this field
|
||||
@ -101,17 +97,6 @@ Schema v1 (Lane A owns it; extend, don't break):
|
||||
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
|
||||
> numbers, no stray keys).
|
||||
|
||||
> **Plan sources (round 6).** A CityPlan can come from two producers behind this one contract, chosen
|
||||
> via `generatePlanFor(seed, source)` (`web/js/citygen/index.js`) — the shell selects with `?plansrc=`:
|
||||
> - `'synthetic'` (default) — `generatePlan(seed)`, the procedural town. Byte-identical, golden `0x3fa36874`.
|
||||
> - `'osm'` — `generatePlanOSM(seed)`, a real-data import from a **checked-in fixture** (`osm_fixture.js`,
|
||||
> real AU shops from thriftgod's OSM cache; **zero network**). Golden `0x34cfdec0`.
|
||||
>
|
||||
> An `'osm'` plan sets `source:'osm'`, reports its **real bounding-box `size`** (not 1024²), and contains
|
||||
> **only real retail types** — no synthetic market square, arcade, dept anchor, milk bars, or stalls.
|
||||
> All structural invariants above hold identically. Consumers that assume a synthetic-only feature
|
||||
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite is parameterized by source.
|
||||
|
||||
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
|
||||
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
|
||||
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
# LANE A — NOTES (cross-lane answers from CityGen)
|
||||
|
||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||||
|
||||
## Round 6 (2026-07-14)
|
||||
|
||||
### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin
|
||||
|
||||
The second plan producer is live behind the same CityPlan contract. Full seam, invariants green
|
||||
(not a partial). What F needs to wire the shell bootstrap + extend the determinism gate:
|
||||
|
||||
- **Selector API** (`web/js/citygen/index.js`):
|
||||
`generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else →
|
||||
the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`,
|
||||
`generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic.
|
||||
- **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then
|
||||
`const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';`
|
||||
and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`.
|
||||
`map.html` already does exactly this (reference impl).
|
||||
- **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990:
|
||||
- synthetic `0x3fa36874` (unchanged this round)
|
||||
- **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`)
|
||||
`selfcheck.js` already asserts both; the smoke can just hash-compare.
|
||||
- **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne
|
||||
shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime.
|
||||
|
||||
**⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):**
|
||||
- `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024².
|
||||
- **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk
|
||||
bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that
|
||||
ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume).
|
||||
- Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/
|
||||
`frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold.
|
||||
- Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived.
|
||||
Parody-transform of names is a documented deferred nicety, not a blocker.
|
||||
|
||||
## Round 4 (2026-07-14)
|
||||
|
||||
### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives
|
||||
|
||||
`tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round:
|
||||
- `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4).
|
||||
- **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the
|
||||
checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the
|
||||
scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓
|
||||
|
||||
### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video)
|
||||
|
||||
Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one:
|
||||
regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract
|
||||
(`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the
|
||||
openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it:
|
||||
|
||||
- **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen
|
||||
`openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall
|
||||
only if a town somehow has no video — never happens in practice).
|
||||
- **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate`
|
||||
shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the
|
||||
same singleton — **gate on either**, they agree.
|
||||
- selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video).
|
||||
|
||||
**⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2
|
||||
plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not**
|
||||
drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js
|
||||
carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins).
|
||||
|
||||
## Round 3 (2026-07-14)
|
||||
|
||||
### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed)
|
||||
|
||||
Your consistency gate prints ~5 warnings per run:
|
||||
`"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`.
|
||||
|
||||
**Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC),
|
||||
never drift.** Evidence:
|
||||
- Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY —
|
||||
e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur.
|
||||
- The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn`
|
||||
`[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts
|
||||
types with registry max ≥ 2, so it can't over-raise a single-storey type.
|
||||
|
||||
**To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max:
|
||||
```
|
||||
permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax
|
||||
// flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift
|
||||
```
|
||||
This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced
|
||||
by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly).
|
||||
|
||||
### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes)
|
||||
|
||||
`hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has
|
||||
`openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent
|
||||
on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold.
|
||||
Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.
|
||||
@ -1,99 +1,5 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 6 (Fable's ROUND6 → Lane B) — place the orphaned street props
|
||||
|
||||
Placed the two published-but-unconsumed street GLBs (`procity_street_bin_01`, `procity_street_bus_shelter_01`)
|
||||
via the existing use-if-ready furniture GLB path (fail-soft to primitives; `?noassets` → primitives,
|
||||
**0 network** verified). All in `furniture.js` (+ a 3-line collider merge in `chunks.js`).
|
||||
|
||||
- **Bins** — genuinely sparse now (one loop, ~1 per block: `s += 78 m`, seeded side per edge), a country
|
||||
town not a mall. Decoupled from benches. GLB (3.0k tris) on its 0.57×0.68 footprint, primitive fallback.
|
||||
- **Bus shelters — on the street graph, and this is the future tram/bus-stop contract.** Deterministic
|
||||
rule: **the midpoint of every MAIN edge whose `hashEdge(id) % 3 === 0`, on the road-side footpath by the
|
||||
kerb, long axis along the edge, open front to the road** — yields **1–3 per town** (2 in seed 20261990).
|
||||
The GLB (2.33×2.4×1.41, 8.0k tris) gets a **solid oriented-rect collider** so the player rounds it
|
||||
(furniture now returns `colliders`; `chunks.js` merges them into `getColliders`). Collision-verified: a
|
||||
straight walk into it is blocked.
|
||||
- **→ whoever builds the V2 tram/bus loop:** call **`busShelterStops(plan)`** (exported from
|
||||
`furniture.js`) → `[{ edgeId, x, z, yaw }]`. It re-derives the stops from the exact same rule the
|
||||
shelters are placed by — pure function of the plan, zero side effects. That is the stop list; don't
|
||||
re-invent it. To move/space stops, change the one rule + `busShelterStops` together.
|
||||
- **Perf (measured, worst view WITH the rig-fleet citizens, midday):** **94k tris · 148 draws** — both
|
||||
well under the ≤200k / ≤300 gates (~100k tri and ~150 draw margin). Determinism holds (seed → same
|
||||
props). All-flags-on (`?winmap=1&dig=1&roster=stream`) composes clean, 0 console errors. Shot:
|
||||
`docs/shots/laneB/bus_shelter.png`. `qa.sh --strict` GREEN.
|
||||
- **→ Lane E (decimation candidate, not blocking):** the `street_bin` GLB is **3.0k tris** — heavy for
|
||||
a prop placed ~15×; I bounded it with the sparse cadence, but a decimate to ~500 tris would give back
|
||||
tri headroom if the combined-lane budget tightens. (Same class as the deferred `novelty_record`.)
|
||||
|
||||
---
|
||||
|
||||
## Round 5 (Fable's ROUND5 → Lane B) — v2: the Vuntra window trick, behind `?winmap=1`
|
||||
|
||||
- **Parallax interior-mapping glass** (V2_IDEAS "window trick"). Behind **`?winmap=1`, default-off.**
|
||||
Single-cube interior mapping in one shared `ShaderMaterial` on the existing window geometry: each
|
||||
glass fragment casts the view ray into a virtual room box and shades the surface it hits (back wall
|
||||
+ seeded shelf silhouettes with product blocks, side walls, floor/ceiling) — a plausible room with
|
||||
real parallax, **zero extra geometry, still 1 window draw/chunk**. Per-shop variation rides
|
||||
attributes (`aTangent` = window right vector, `aTint` = seeded room colour, `aSeed` = shelf
|
||||
variation), **not per-shop materials**. Verified live: rooms are visible through the glass with
|
||||
parallax day + night.
|
||||
- **§3.5 respected** via the same `uHour/uNight` + `aHours` the closed-facade system uses: at night an
|
||||
**open shop's room glows warm** ("Video Regal" lit), a **closed shop's room is dark** ("Second Time"
|
||||
dark) — screenshots in `docs/shots/laneB/winmap_*.png`.
|
||||
- **v2 prime law held.** `WINMAP` is read straight from the URL in **buildings.js** — **no shell seam,
|
||||
so F only needs a flags-table row, no `index.html` wiring.** Flag-OFF path is byte-identical to v1:
|
||||
`windowQuad` collapses to the v1 `hoursQuad` (no extra attributes) and the finalize uses the v1
|
||||
`windowMaterial()` — verified: flag-off windows are `MeshStandardMaterial` with no `aTangent`;
|
||||
flag-on are the interior `ShaderMaterial` with `aTangent`. Seeded from `shop.seed` (deterministic).
|
||||
- **Budget + fetches:** flag-on worst continuous-walk view with citizens **122–168 draws** (≤300);
|
||||
the shader is **100% procedural** — measured **0 fetch delta** vs the `?noassets` baseline (both 22
|
||||
= the pre-existing facade/ground skin JPEGs). `?noassets=1&winmap=1` adds no network. **0 console
|
||||
errors** in all modes. `qa.sh --strict` GREEN (4/4); `GOLDEN.hash` untouched (no plan change).
|
||||
|
||||
**→ Fable (F2 flags table):** `?winmap=1` — owner Lane B — state landed, default-off — needs **no
|
||||
index.html wiring** (self-read in buildings.js). Smoke: boot `?winmap=1`, walk a block, glass shows
|
||||
rooms; night open=warm / closed=dark.
|
||||
|
||||
---
|
||||
|
||||
## Round 4 (Fable's ROUND4 → Lane B) — done
|
||||
|
||||
- **B1 — closed-facade visuals (§3.5, the headline).** Closed shops now render **dark windows + a
|
||||
red CLOSED plate**; open shops (esp. the one `openLate` **video rental** at night) render **lit
|
||||
windows**; the door tooltip reads `🔒 <name> — CLOSED · opens HH:00` on aim. **Batched — the facade
|
||||
atlas is untouched:** per-shop open/closed rides on a per-vertex `aHours` (vec2 = the shop's
|
||||
`[open,close]`) feeding TWO shared shaders (window emissive gated by *open AND night*; plate alpha
|
||||
gated by *closed*), driven by ONE pair of shared uniforms (`uHour/uNight`) updated **once per
|
||||
day-segment change** — no per-shop materials, no per-frame poll. lighting.js dispatches a
|
||||
`procity:segment` event (numeric hour matching Lane F's `currentHour()`); buildings.js listens and
|
||||
calls `setFacadeHours`. Verified live: **22:00 → only "Video Regal" lit, every other facade CLOSED**;
|
||||
midday → all open, no plates; tooltip correct. **Worst view with citizens: 200 total draws** (≤300).
|
||||
- **B2 — shot harness (gate-6).** (a) **Letterbox "fixed":** the bars were **not** a composer bug (I
|
||||
proved the composer/renderer/camera are correctly 1280×720 in headless at every stage) — they were
|
||||
`street_noon`/`arcade`/`warehouse_fringe` poses **jamming the camera against a big building wall**.
|
||||
Reframed those bookmarks to look *down a street* (`streetViewPose`), so all 10 tour shots now fill
|
||||
the frame. Also added a defensive `composer.setSize` sync in `DBG.render` per F's request. (b) Added
|
||||
the **3 missing poses** F listed (`crossroads_busy`, `residential_collar`, `warehouse_fringe`) —
|
||||
resolved from the live plan (busiest node / house-or-backstreets lot / outer-ring street), always a
|
||||
valid full-frame pose. `night_neon` now frames the open video shop glowing amid its CLOSED
|
||||
neighbours (the §3.5 money shot). `tools/shots.py` → 10 un-letterboxed shots, **0 console errors**.
|
||||
- **B3 — street-furniture GLB upgrade + `novelty_record`.** furniture.js now upgrades primitives to
|
||||
depot GLBs via a **synchronous use-if-ready** check at chunk-build (no async write into a live
|
||||
chunk; `?noassets` → primitives, **0 network** verified). Placed the **novelty_record** landmark on
|
||||
the footpath in front of every record store (and a food_cart by market stalls / milk bars).
|
||||
**⚠ measured GLB tris:** bench **1.9k** ✓, food_cart **5.0k** ✓, streetlight **5.0k** ✓, but
|
||||
**novelty_record is 26.5k** (5× the ≤5k `glb_law`) — × many instances it spiked the busiest view to
|
||||
**574k tris**. So novelty_record stays on its **low-poly primitive** (giant vinyl disc, budget-safe)
|
||||
until Lane E decimates the GLB — **same treatment the ped rigs need (→ Lane E)**. With that, the
|
||||
bench GLB verified sitting on its exact footprint (0.44×1.21), tris back to ~25k.
|
||||
|
||||
`bash tools/qa.sh --strict` **GREEN** (4/4) after these changes. Round-4 files: `dbg.js`, `buildings.js`,
|
||||
`lighting.js`, `hud.js`, `furniture.js` (+ these docs, + `docs/shots/laneB_r4/`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
|
||||
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
|
||||
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
|
||||
@ -124,14 +30,8 @@ denser than the fixture the ≤300 gate was sized against.
|
||||
|
||||
Streetscape numbers below are **buildings + ground + furniture only** (what Lane B owns), measured by
|
||||
a scripted continuous walk (real streaming + disposal, so ≤25 live chunks — no orphan-chunk inflation).
|
||||
|
||||
**Round-3 re-measure WITH Lane D citizens (pop 140, the shipped default).** Driving the real loop
|
||||
(`chunks.update` + `citizens.update`) along the dense corridors via `window.DBG`, up to **125 active
|
||||
peds** at midday: worst continuous-walk view **263 scene / 275 total** at **MIDDAY** and **275 total
|
||||
at NIGHT** — **≤300 everywhere**, ~25 draw margin. Peds are a batched impostor layer (~1–2 draws for
|
||||
the whole near tier), so they cost far less than their count. Radius-2 auto is already the spawn
|
||||
default for a big city; no config change was needed. (Fable's independent integrated read is ~191 on a
|
||||
typical main-street view.)
|
||||
Lane F's citizens add on top; Fable measures the *integrated* worst main-street view (pop 140) at
|
||||
**~191 draws**, also under gate.
|
||||
|
||||
| metric | measured | gate | status |
|
||||
|---|---|---|---|
|
||||
@ -185,11 +85,6 @@ exceeds the radius-2 set. Verified day + night, facades still textured, awning c
|
||||
- **Night lighting is faked** (Vuntra-style): window + streetlamp **emissive** toggled by a night
|
||||
flag, plus bloom — no per-streetlight real lights. `ChunkManager.setNight()` re-applies to chunks
|
||||
streamed in while already night.
|
||||
- **`scene.environment` is set** (round 3, `lighting.attachRenderer`): a neutral PMREM'd sky→ground
|
||||
gradient (no asset). Lane D's rig-fleet peds bake their impostor atlas from `scene.environment`
|
||||
(`sim.js`) and go dark if it's null; set here *before* `CitizenSim` constructs. One env for all
|
||||
segments (the atlas bakes once; the mostly-non-metal streetscape is barely affected). Verified set
|
||||
in street/night/interior + after interior-return (`interior_mode` only swaps `scene.background`).
|
||||
- **Shared materials, city-wide** (`skins.js`). Facades share **one atlas material** (all skins in a
|
||||
2048² atlas); shells + parapets + posts are **one** InstancedMesh/chunk (per-instance colour); all
|
||||
awning/canopy skins are **one** InstancedMesh/chunk (per-instance colour). Signs are a per-chunk
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
# LANE C — GLB validation report → for Lane E + Fable
|
||||
|
||||
## ROUND 5 / v1.1 (2026-07-14) — record_crate fixed + counter_till mapped
|
||||
|
||||
Lane E's R4-late re-exports validated and mapped (LANE_E_NOTES → C). Contact sheet:
|
||||
[`docs/shots/laneC/glb_v1_1_r5.jpg`](../shots/laneC/glb_v1_1_r5.jpg).
|
||||
|
||||
- **`record_crate` FIXED** (E stripped the sourceless AO/roughness texture slots) — loads in the vendored
|
||||
GLTFLoader (0.46·0.41·0.63 m, minY≈0, 5,000 tris). `crate`/`recordBin` now **upgrade** to plastic
|
||||
crates with the procedural sleeves inside (was fail-soft to primitive). Removed the stale comment.
|
||||
- **`counter_till` MAPPED** (`counter → 'counter_till'` in `KIND_TO_GLB`) — the new ~1.6 m timber counter
|
||||
WITH a modelled till (1.61·1.15·0.64 m, minY≈0, 32k tris). Footprint-fit + floor-plant work unchanged;
|
||||
yaw 0. Keeper-stand pose is a room-local pose, unaffected by the geometry swap (verified in HUD).
|
||||
- **Double-till resolved (decision, documented in glb.js):** because `counter_till` carries its own till,
|
||||
the separate `cash_register` counter-top attach is **dropped whenever the counter GLB is live** (falls
|
||||
back to cash_register on the primitive counter if `counter_till` is ever absent). `milkshake_mixer`
|
||||
stays on the milk-bar bench, lifted +0.10 m to sit on the taller GLB counter. Soak: **0 rooms with a
|
||||
visible primitive till** (no double till).
|
||||
- **Sweep + soak (GLB on)**: `throws 0 · pathFail 0 · determinismFail 0 · worst 10.4 ms`. Leak: 0/0
|
||||
against a saturated baseline (the transient 2 geo / 4 tex on first sight is `record_crate` + `counter_till`
|
||||
cache-fill, bounded — confirmed by a double-soak). Primitive path unchanged by C1 (glb.js-only).
|
||||
|
||||
Still open: nothing blocking. The `counter_till` material reads over-shiny in some lighting (TRELLIS
|
||||
shell-soup) — cosmetic, E's bake pass will help; not a Lane C issue.
|
||||
|
||||
### C3 — interior re-measure after Lane E's hero-prop bake (`c522464`)
|
||||
|
||||
E baked the 6 heavy hero props to ≤8k tris (listening_booth 53k→8k, drinks_fridge 77k→8k,
|
||||
milkshake_mixer 73k→8k, arcade_cabinet 25k→8k, counter_till 32k→8k; record_crate/cash_register were
|
||||
already ≤6k). **This closes the R4 milk-bar over-budget flag.** Per-room GLB-on triangles, single
|
||||
interior, seed 1990 (measured with fitting URLs cache-busted to bypass this dev browser's stale HTTP
|
||||
cache — production clients fetch the baked versions directly):
|
||||
|
||||
| room | R4 GLB-on | R5 post-bake | draws |
|
||||
|---|---|---|---|
|
||||
| **record** | 115,867 (record_crate was broken) | **43,621** | 322 |
|
||||
| **milkbar** | **468,106** (the flagged room) | **38,901** | 573 |
|
||||
| video | 61,669 | 21,361 | 458 |
|
||||
| book | 102,998 | **106,950** | **1,245** |
|
||||
| opshop | 58,230 | 62,182 | 702 |
|
||||
| pawn / toy / dept / stall | ≤58k | 9k / 28k / 37k / 24k | ≤512 |
|
||||
|
||||
Every interior is now well within a sane single-room budget (≤107k tris). The record store — the C3
|
||||
target, quoted as ~273k pre-bake — is **43.6k** post-bake. Remaining note: `book` is the draw-call
|
||||
outlier (1,245 draws) from ~9 bookshelf GLBs + the ~600 primitive spine meshes; tris are fine (107k),
|
||||
and interiors render one-at-a-time (Lane F swaps scenes, the street isn't drawn behind them), so this
|
||||
does not affect gate 3. If draw calls ever matter for interiors, instancing the spines is the lever —
|
||||
noting for a future round, not fixing now.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 4 update (2026-07-14) — the 4 new hero props + cash_register
|
||||
|
||||
Mapped and validated the 5 round-4 hero GLBs (manifest now 14 fittings). Loaded via the **live
|
||||
`depot:` path** (all published, HTTP 200); the R3 `_local_glb/` copy is deleted. Screenshot evidence:
|
||||
[`docs/shots/laneC/glb_heroprops_r4.jpg`](../shots/laneC/glb_heroprops_r4.jpg).
|
||||
|
||||
**All 5 load cleanly** (unlike the still-broken `record_crate`), follow `glb_law` (minY≈0, real metres),
|
||||
and match their manifest footprints — **no per-kind yaw correction needed (yaw 0)**:
|
||||
|
||||
| id / file | native x·y·z (m) | tris | maps to | placement |
|
||||
|---|---|---|---|---|
|
||||
| `arcade_cabinet` | 0.75·1.70·0.85 | 25,142 | **new** `arcadeCabinet` primitive (video recipe) | floor, aisle zone, focal |
|
||||
| `listening_booth` | 0.88·1.60·0.71 | 53,502 | `listeningCorner` (record) | floor, window zone, focal |
|
||||
| `drinks_fridge` | 0.85·1.60·0.96 | 77,047 | `fridge` (milkbar) | floor, against wall |
|
||||
| `milkshake_mixer` | 0.38·0.50·0.27 | 73,492 | counter-top (milkbar) | ON counter benchtop (left slot) |
|
||||
| `cash_register` | 0.42·0.35·0.34 | 5,999 | counter-top (all types) | ON counter benchtop (till slot); hides primitive till |
|
||||
|
||||
New mechanism: **counter-top GLB attach** (`glb.js attachCounterTop`) — cash_register replaces the
|
||||
primitive till on every counter; milkshake_mixer sits on the milk-bar benchtop. The counter primitive
|
||||
(correct height + keeper stand) is kept; only the till mesh is hidden. Focal props made reliable
|
||||
(priority 6 + placed first): **24/24 appearance** across seeds, 0 carves, 0 path-fails.
|
||||
|
||||
**Sweep + soak with all R4 mappings on**: `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
|
||||
leakTex 0 · worst build 7.3 ms`. GLB clones share cached geometry (SkeletonUtils.clone) → per-room
|
||||
dispose is leak-free (baseline taken after a full 9-type warmup).
|
||||
|
||||
### ⚠ Interior tris — one room over budget (task 4 report, NOT fixed per integrator decision #2)
|
||||
|
||||
Per-room triangles, single interior rendered alone (primitives vs GLB-on), seed 1990:
|
||||
|
||||
| room | prims tris | GLB-on tris | GLB-on draws | note |
|
||||
|---|---|---|---|---|
|
||||
| pawn | 812 | 7,019 | 161 | fine |
|
||||
| opshop | 2,476 | 58,230 | 706 | fine |
|
||||
| video | 5,712 | 61,669 | 461 | arcade 25k dominates — fine |
|
||||
| book | 6,372 | 102,998 | 1,249 | high **draws** (≈600 primitive spines) |
|
||||
| record | 2,530 | 115,867 | 337 | listening_booth 53k — fine |
|
||||
| **milkbar** | 1,662 | **468,106** | 578 | **OVER** — `drinks_fridge` (77k) ×up-to-3 + `milkshake_mixer` (73k) |
|
||||
|
||||
**→ Lane E bake-pass priority (future, not v1):** `drinks_fridge` (77k) and `milkshake_mixer` (73k) are
|
||||
grossly over-tessellated for their visual role (a fridge and a bench appliance). Baking these two to
|
||||
~5–10k would drop the milk-bar room from ~468k to well under 150k. `cash_register` (6k) and
|
||||
`arcade_cabinet` (25k) are already reasonable. Interiors render one-at-a-time (Lane F swaps scenes, the
|
||||
street is not drawn behind them), so this does **not** affect gate 3 (street budget) or the v1 tag — but
|
||||
it's the #1 candidate for the bake pass. Not blocking; reporting per task 4.
|
||||
|
||||
### Still open (Lane E)
|
||||
- `record_crate` — STILL fails to load (`Cannot read ...'uri'`); record bins/crates stay primitive. Re-export.
|
||||
- `counter` — still the 4 m till-less asset; interiors keep the primitive counter. A ~2 m counter-with-till
|
||||
would let me drop cash_register's separate attach and map `counter` directly. When both land I re-map (C task 5).
|
||||
|
||||
---
|
||||
|
||||
## ROUND 3 report (superseded above where they overlap) → for Lane E + Fable
|
||||
|
||||
*PROCITY-C, 2026-07-14. Round-3 task "GLB path visual validation" (ROUND3_INSTRUCTIONS §Lane C).
|
||||
Validated Lane E's 9 fitting GLBs from `pipeline/_normalized/` in real interior rooms via the
|
||||
`interiors/glb.js` upgrade path (`useGLB` on). Verdict below; the 5 "good" assets are live.*
|
||||
|
||||
## How validated
|
||||
- Served the 9 `procity_fit_*.glb` locally (`web/assets/_local_glb/`, copied from `pipeline/_normalized/`)
|
||||
and pointed `glb.js` at them via a new `manifest.localBase` (loads before/without the depot, per the
|
||||
round-3 instruction). Production still uses `depot:<file>` unchanged.
|
||||
- Toggle **GLB props** on the interior test page (`web/interior_test.html`) rebuilds every shop type
|
||||
with the GLBs and re-renders once they load; a headless sweep+soak checks throws/leaks/perf.
|
||||
- Convention followed: `glb_law` (metres, +Y up, base at origin) ✓ every asset measured `minY≈0`;
|
||||
`facing` ("Lane B/C rotate directional props") — Lane C now applies per-kind yaw in `glb.js`.
|
||||
|
||||
## Result — soak with `useGLB` on (50 rooms, all types)
|
||||
`throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 · leakTex 0 · worst build 10.5ms (<50) ·
|
||||
256 GLB upgrades applied`. **GLB textures/geometries dispose leak-free on room exit** (loader-cached
|
||||
+ clone-shared; baseline taken after warmup). Acceptance met.
|
||||
|
||||
## ✅ Good — live now (5 assets)
|
||||
| id / file | native (x·y·z m) | notes |
|
||||
|---|---|---|
|
||||
| `wire_shelf` · procity_fit_wire_shelf_01.glb | 1.17 · 1.8 · 0.44 | clean, full height, faces right (yaw 0) |
|
||||
| `clothes_rack` · procity_fit_clothes_rack_01.glb | 1.34 · 1.5 · 0.43 | garments hang correctly, casters read well |
|
||||
| `bookshelf` · procity_fit_bookshelf_01.glb | 0.2 · 2.0 · 1.02 | **needs +90° yaw** — modelled wide along Z; Lane C rotates it. Then spines face the aisle ✓ |
|
||||
| `work_table` · procity_fit_work_table_01.glb | 0.44 · 0.75 · 1.04 | **needs +90° yaw** (Z-major); goods sit on top ✓ |
|
||||
| `cube_shelf_wide` · procity_fit_cube_shelf_wide_01.glb | 0.75 · 1.1 · 1.24 | **needs +90° yaw**. ⚠ 1.24 wide → in narrow (2-col ≈0.84m) cube slots it clamps to ~0.75m tall. Fine in wide slots. Optional: a ~0.8m-wide cube variant. |
|
||||
|
||||
Lane C applies the +90° yaw for the three Z-major assets (per the `facing` convention — no action needed
|
||||
from Lane E unless you want to re-bake them Z-forward). All plant on the floor; no z-fighting with the
|
||||
procedural stock/decor that rides on top.
|
||||
|
||||
## ⚠ Mismatches to fix (2 assets)
|
||||
1. **`procity_fit_record_crate_01.glb` — BROKEN, will not load.**
|
||||
`GLTFLoader.loadAsync` throws `TypeError: Cannot read properties of undefined (reading 'uri')`.
|
||||
The file has valid glTF magic and is 858 KB, but references an undefined `uri` (missing embedded
|
||||
image/buffer, or an extension the shipping bare `core/loaders.js` GLTFLoader can't resolve — it is
|
||||
the **only one of the 16 that fails** that loader; the other 8 fittings + street furniture load fine).
|
||||
Impact: record bins/crates fall back to primitives (fail-soft, no crash). **Please re-export** (and if
|
||||
it uses KTX2/meshopt, flag it — the core loader has no KTX2/meshopt/Draco decoder configured).
|
||||
2. **`procity_fit_counter_01.glb` — too large for the interior counter slot; no till.**
|
||||
It's a real **4.02 m-long × 1.1 m** counter. Interior counter slots are ~1.4–2.2 m, so fitting to the
|
||||
slot squashes it to ~0.6 m tall, and it has no till/register modelled. Lane C therefore **keeps the
|
||||
counter primitive** (correct height + till + keeper stand). If you want a GLB counter for interiors,
|
||||
a **~2 m-long counter with a till** would drop straight in (re-add `counter` to `KIND_TO_GLB`).
|
||||
|
||||
## Notes for Lane E's local-depot mode (your round-3 §2)
|
||||
When `?localdepot=1` / `core/loaders.js` resolves `depot:<file>` → local, Lane C needs **no change**:
|
||||
`glb.js` uses `depot:<file>` by default and only used `localBase` as a standalone validation shim. Point
|
||||
your local resolver at `pipeline/_normalized/` and my `useGLB` path works through it as-is.
|
||||
|
||||
Screenshot evidence: [`docs/shots/laneC/glb_validation_grid.jpg`](../shots/laneC/glb_validation_grid.jpg).
|
||||
@ -1,105 +0,0 @@
|
||||
# LANE C — cross-lane notes (PROCITY-C)
|
||||
|
||||
## → Lane F: `?stock=real` smoke for the strict harness (round-7 C1 landed)
|
||||
|
||||
`?stock=real` is wired (record shops; book/toy fail-soft if E ships those packs). Smoke recipe:
|
||||
- Preload once: `import { preloadStockPack } from interiors.js; await preloadStockPack('record')`.
|
||||
- Boot `?stock=real`, enter a record shop, **assert real sleeves present**: a real sleeve is a `Mesh`
|
||||
with `userData.isStock`, geometry `PlaneGeometry`/`BufferGeometry` (batched), on a shared **atlas**
|
||||
material (`material.map` = the pack atlas, not a per-item canvas). Simplest assert: after build,
|
||||
`let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0` — with
|
||||
the pack loaded the record bins' sleeves batch to **one** atlas mesh (parody canvas would be ~6 card
|
||||
meshes). Or open the dig (`?dig=1&stock=real`) and assert a pulled sleeve's panel title is a pack title.
|
||||
- **Fail-soft**: pack missing → parody canvas + one `console.warn` (no throw). `?noassets` unaffected.
|
||||
- **drawSweep still ≤350** with `?stock=real` (record 59 draws — the atlas batches all sleeves).
|
||||
|
||||
## → Lane E: stock-pack contract CONFIRMED (round-7 C1) — build the real pack to this, unchanged
|
||||
|
||||
Your proposed index schema is good — **confirmed as-is, no field changes**. Building `?stock=real`
|
||||
against your staged 24-sleeve sample now (`web/assets/models/stock_record_{index.json,atlas_00.webp}`);
|
||||
the real covers drop in unchanged. The contract C consumes:
|
||||
|
||||
```jsonc
|
||||
// stock_<type>_index.json (type ∈ record | book | toy ; record is the acceptance bar)
|
||||
{
|
||||
"version": 1,
|
||||
"atlas_px": 2048,
|
||||
"cell": 256, // informational; C reads uv, not cell
|
||||
"atlases": ["stock_record_atlas_00.webp", ...], // 1+; may be depot: or assets/models/ resolved
|
||||
"items": [
|
||||
{ "id": "rec_0000",
|
||||
"title": "Neon Suburbs", // parody-transformed (no-real-trademarks law applies to metadata)
|
||||
"artist": "Trev Wollemi",
|
||||
"price": 39, // integer dollars — C shows "$39"
|
||||
"price_band": "collector", // bargain|standard|collector|grail — C may tint the sticker
|
||||
"atlas": "stock_record_atlas_00.webp", // which entry in `atlases`
|
||||
"uv": [0.0, 0.0, 0.125, 0.125] } // [u0,v0,u1,v1] cover rect, ORIGIN TOP-LEFT
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Agreements / who-does-what:
|
||||
- **UV origin is top-left (image-natural) — C flips V for WebGL** (`v_gl = 1 - v`). Keep your index
|
||||
image-natural; the consumer handles the flip. (Sample verified against this.)
|
||||
- **Resolution**: C loads `stock_<type>_index.json` from a base (default `assets/models/`, i.e. your
|
||||
staged path — same-origin, works offline). Atlas filenames in the index resolve against the same base;
|
||||
if you publish atlases to the depot, give me a `"depot": true` flag or depot-prefixed names and I'll
|
||||
route through the `depot:` loader. For now the sample loads local.
|
||||
- **Seeded pick per bin is mine** (same `shop.seed` → same crate contents). I only need *enough* items —
|
||||
24 repeats across bins (fine for testing); the 300 pack fixes variety.
|
||||
- **Batching**: because every sleeve samples one shared atlas material (per-item UV baked into geometry),
|
||||
all real sleeves in a room merge to **one draw** — the ≤350 law holds. Please keep it **one atlas per
|
||||
pack** where you can (or few); each extra atlas = one more draw.
|
||||
- **Fail-soft**: index/atlas missing or `?stock` off → parody canvas (warn once). `?noassets` untouched.
|
||||
- **Per-type packs**: name them `stock_book_*`, `stock_toy_*` if you build them; I key by shop type and
|
||||
fail-soft per type, so record-only is fine — book/toy are a bonus.
|
||||
|
||||
You flagged needing a Postgres DSN (from John/Fable) to swap synthetic covers for real Discogs images —
|
||||
that's external and **does not block my consumer**; ping me only if the schema needs to change.
|
||||
|
||||
|
||||
|
||||
## → Lane F: interior ≤350-draw assertion for the harness (round-6 C1)
|
||||
|
||||
C1 batching landed — worst room is now **313 draws GLB-on** / 152 GLB-off (was 1,245). The test page
|
||||
exposes the assertion for your harness:
|
||||
|
||||
- `window.PROCITY_C.drawSweep({ glb: true|false })` → `{ worst, worstAt, pass, law: 350, perType }`.
|
||||
Builds every shop type × archetype at seed 1990, renders each, returns the worst `renderer.info.render.calls`.
|
||||
It's already folded into the interior soak's PASS/FAIL. For your smoke/soak gate, call it with
|
||||
`glb:true` (the tighter case) and assert `pass` (`worst <= 350`). Headless-safe, ~1s.
|
||||
- The draw law is a Lane-C constant (`DRAW_LAW = 350` in interior_test.html) so it's one number to bump
|
||||
if the budget ever changes. Ping me if you'd rather it read from a shared config.
|
||||
|
||||
## → Fable / Lane F: v1.1 is ready (round-5 C1 done)
|
||||
|
||||
**C1 committed.** Lane E's re-exports are mapped + validated in `web/js/interiors/glb.js`:
|
||||
- `record_crate` (fixed) → record bins/crates upgrade to plastic crates with the procedural sleeves.
|
||||
- `counter → counter_till` → the real ~1.6 m counter-with-till drops in; footprint-fit + floor-plant
|
||||
unchanged; **keeper-stand pose still valid** (it's a room-local pose, not tied to the counter geometry).
|
||||
- **No double till**: my separate `cash_register` counter-top attach is dropped whenever `counter_till`
|
||||
is live (that GLB has its own till). `milkshake_mixer` still on the milk-bar bench.
|
||||
- GLB soak: `0 throws / 0 path / 0 determinism / leak-free / worst 10.4 ms`, **0 rooms with a visible
|
||||
primitive till**. Primitive path byte-unchanged (C1 touched only `glb.js`). `qa.sh --strict` green.
|
||||
|
||||
**→ You can tag v1.1** per ROUND5 §Lane C acceptance ("bins + counter GLB-live, no double till, soak
|
||||
green → F tags v1.1"). Evidence: [`docs/shots/laneC/glb_v1_1_r5.jpg`](../shots/laneC/glb_v1_1_r5.jpg),
|
||||
report in [`LANE_C_GLB_VALIDATION.md`](LANE_C_GLB_VALIDATION.md).
|
||||
|
||||
## → Lane F: input/mode hook I need for `?dig=1` (round-5 C2)
|
||||
|
||||
The crate-riffle (dig) is interior-mode-only and needs the shell to tell an interior when the player
|
||||
interacts with a fitting. Proposed seam (you own the shell/`interior_mode.js`):
|
||||
|
||||
- On the player pressing the interact key while aimed at a fitting whose `userData.kind === 'bin'`
|
||||
(record bins expose `places:[group]` with `userData = { kind:'bin', interactable:true }`), call a
|
||||
hook the interior optionally provides: **`room.onInteract?.(hitObject)`** — where `room` is the object
|
||||
returned by `buildInterior(...)`. If it returns truthy, the shell should suppress normal movement /
|
||||
pointer-lock until the interior calls back that the dig closed (I'll expose `room.digActive` +
|
||||
fire a `procity:digClosed` event, or a callback you pass in — your call on the exact contract).
|
||||
- Gate the whole thing on `?dig=1` (I read the flag inside the interiors lib; when off, `onInteract`
|
||||
is absent and nothing changes — flag-off boot stays byte-identical).
|
||||
|
||||
I'm building the dig against my own test page (`web/interior_test.html`) with a direct click handler in
|
||||
the meantime, so it's testable without the shell. Tell me if you'd prefer a different hook shape and
|
||||
I'll match it. — PROCITY-C
|
||||
@ -4,226 +4,6 @@
|
||||
Reference stack ported from `90sDJsim/web/world/index.html` ~405–520 (loadRig/spawnRig/_canon/
|
||||
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
|
||||
|
||||
---
|
||||
|
||||
## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready
|
||||
|
||||
**Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed
|
||||
roster (perChunk 16) is now under budget in a real town. Flip it default-on.**
|
||||
`qa.sh --strict` GREEN. All numbers in-shell (real town "Boolarra Heads", seed 20261990).
|
||||
|
||||
### Merge validation (E1 — `pipeline/merge_ped.py`, `447188a`) ✅
|
||||
- **Every one of the 19 peds is now 1 mesh / 1 material** (was 8 meshes / 2 materials) → **~1 draw per
|
||||
near-rig**; fleet **116→24 draws at the 24-cap** (E's own count: 92→19). Bonus: GPU memory dropped
|
||||
too (geometries 205→160, textures 141→90 in-shell).
|
||||
- Rigs **bind + animate** (walk/idle) — mixer 0.2–0.3 ms; **no T-pose**. Skinning/skeleton preserved
|
||||
(65 joints), clips retarget unchanged.
|
||||
- **Silhouettes + atlas materials intact** — hi-vis worker (vest/helmet/boots), suit, business, comical
|
||||
kid all read crisp, no atlas seams/melting; **identity variety preserved** (`pickRig` unchanged).
|
||||
- **Impostor bake clean** (19 subjects, 16×5 atlas); **determinism holds** (507 chunk-keyed identities
|
||||
re-derive from seed).
|
||||
|
||||
### The flip numbers (full density, `perChunk 16` — no reduced-density concession)
|
||||
| metric | pre-merge (R6) | **post-merge (R7)** | budget |
|
||||
|---|---|---|---|
|
||||
| worst continuous-walk street view **draws** | 356 ✗ | **241** ✅ | ≤300 |
|
||||
| worst view **tris** | ~66k | **65,899** ✅ | ≤200k |
|
||||
| worst-view near-rigs | (throttled to 8) | **17** (full) | cap 24 |
|
||||
| draws / near-rig | ~7 | **~1** | — |
|
||||
| 30-chunk soak (3 walks = 726 builds/disposes) | leak-free | **leak-free, 0 GPU delta** | baseline |
|
||||
| heap over soak | 50→77→54 | 78→63 (GC, stable) | stable |
|
||||
|
||||
### Full-density shipping defaults (set this round)
|
||||
`enableStream` default is now **`perChunk 16`, `radius 2`** (was 8). Near-cap stays 24 (now 24 draws,
|
||||
not 168). `NIGHT_LIVELY_FLOOR 0.5` for the open-late block (R6). No other knobs change.
|
||||
|
||||
### → Lane F: the flip wiring in `web/index.html` (F owns the shell — decision #1, flip on my word)
|
||||
Invert the R5/R6 flag: **stream is the default; `?roster=v1` is the escape hatch.**
|
||||
```js
|
||||
const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster
|
||||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||||
graph: plan.streets, fleet,
|
||||
chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
|
||||
```
|
||||
Hours-aware (optional, recommended): after construction, `citizens.setNightLivelyChunks(keys)` where
|
||||
`keys` = the openLate shop's lot→chunk + 1-ring (`sim.chunkKeyAt(x,z)`; openLate = `hours[1] >= 22`).
|
||||
Everything else (`setTimeOfDay`/`setExposure`/`update`/`setPaused`) is unchanged. The
|
||||
hook-driven window (`onChunkBuilt`/`onChunkDisposed` → `sim.onChunkBuilt/onChunkDisposed`) is still the
|
||||
preferred precise option; poll works with zero wiring.
|
||||
|
||||
**Verified in my harness (test page + runtime-enabled shell):** no-flag boot → `streamMode:true`,
|
||||
perChunk 16, determinism ✓; `?roster=v1` → `streamMode:false`, fixed roster, determinism ✓. The
|
||||
in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven
|
||||
to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell).
|
||||
|
||||
---
|
||||
|
||||
## ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call)
|
||||
|
||||
**Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density
|
||||
default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced
|
||||
density.** Flag stays default-off this round. `qa.sh --strict` GREEN (sim.js doesn't touch its gates).
|
||||
All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled
|
||||
(F wires `?roster=stream` in F2 — call-site in the R5 section below).
|
||||
|
||||
### Soak (gate-2 + far-field, `?roster=stream`) ✅
|
||||
- **Leak-free**: 3 full street-graph walks = **242 chunk builds / 242 disposes → 0 GPU geometry / 0
|
||||
texture delta** (renderer.info constant 205/141); **16 shop enter/exit cycles → 0 delta**. JS heap
|
||||
oscillates 50→77→**54** MB (GC recovers — not monotonic, no leak).
|
||||
- **Determinism**: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell.
|
||||
- **Flag-off byte-identical**: shell citizen-0 signature `0:13:7:1.786:1.493:3:-1` (R4 golden), still
|
||||
exact after this round's changes. 0 console errors across the soak.
|
||||
|
||||
### Hours-aware density ✅ (implemented this round)
|
||||
Each chunk thins per-frame by the same `densityAt(tod)` curve v1 uses. New: `setNightLivelyChunks(keys)`
|
||||
gives the open-late block a night floor (`NIGHT_LIVELY_FLOOR=0.5`). Measured: the open-late **video**
|
||||
shop (hours 11–23) block holds ~half its crowd at night while ordinary streets go near-empty
|
||||
(**ordinary 65→6, ~90% drop; open-late block 31→16**). **Lane F call-site**: compute the openLate
|
||||
shop's lot→chunk (+ 1-ring) and `citizens.setNightLivelyChunks(keys)` once after enabling stream
|
||||
(`sim.chunkKeyAt(x,z)` gives the key). Empty set = uniform thinning (safe default).
|
||||
|
||||
### Composition with `?dig=1` ✅
|
||||
The streamed roster is **street-tier only**: the shell frame loop calls `citizens.update` in the
|
||||
street branch only, so the roster is fully inert while a shop/dig is open — no shared state, no fight.
|
||||
Proven: enter/exit record shops (the dig context) with stream on is leak-free and the roster resumes
|
||||
intact. (The riffle itself is Lane C+F's flag; couldn't trigger its raycast open via synthetic input
|
||||
without pointer-lock, but the composition claim is mode-separation, which holds by construction.)
|
||||
Same reasoning covers the all-on combo — roster shares no state with `winmap` (B glass) or `dig` (C).
|
||||
|
||||
### Perf / budget — the ONE gap ⚠️ (default-on blocker at full density)
|
||||
- Stream vs v1 (pop 140) at the busiest node: frame **+0.29 ms** (0.89 vs 0.60 ms), draws ~parity;
|
||||
most of the ~500 active are far = cheap position-advance only. Mixer stays 0.1–0.3 ms.
|
||||
- **Draw budget**: worst continuous-walk street view must stay ≤300 (CITY_SPEC). Root cause: each
|
||||
decimated ped is **8 sub-meshes but only 2 materials → ~7 draws per near-rig**. So a dense street of
|
||||
near-rigs blows the budget:
|
||||
|
||||
| stream `perChunk` | worst-view draws | worst near | density (within 70m, median) |
|
||||
|---|---|---|---|
|
||||
| 16 | **356** ✗ | 15 | ~26 |
|
||||
| 12 | 314 ✗ | 10 | ~18 |
|
||||
| **8 (new default)** | **291** ✓ | 10 | ~13 |
|
||||
|
||||
So it ships **budget-safe at `perChunk=8` today** (~13 median within 70m — constant + camera-following
|
||||
vs v1's uniform ~13 that *thins as the town scales*; and it concentrates **~10 near-tier rigs vs v1's
|
||||
0–1** — the visible upgrade). Full density (`perChunk 16`, ~2× the crowd) needs the budget headroom.
|
||||
|
||||
### Recommendation for the R7 default-on flip
|
||||
1. **Land the ped sub-mesh merge** (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig).
|
||||
Then `perChunk 16` worst-view ≈ 260 draws — full density under budget. It's a `rigs.js` fleet-load
|
||||
optimisation and it is **F1-safe in practice**: v1's default/spawn view has ~0 near-rigs, so the
|
||||
flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the
|
||||
stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge
|
||||
(verify animation). **This is the single thing standing between stream and default-on.**
|
||||
2. Until then: default-on is safe at `perChunk=8` (reduced density) — flip is a judgement call on
|
||||
whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd.
|
||||
|
||||
*Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop
|
||||
spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).*
|
||||
|
||||
---
|
||||
|
||||
## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below
|
||||
|
||||
**As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical.
|
||||
`tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates).
|
||||
|
||||
**What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly
|
||||
sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of
|
||||
`(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks
|
||||
around the camera. Density becomes constant per unit street and follows the player. Everything
|
||||
downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays
|
||||
**global across live chunks**.
|
||||
|
||||
**Measured (test page, 9×9 fixture grid, ~±208m):**
|
||||
- **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere —
|
||||
vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street
|
||||
density at equal total cost, and it doesn't thin as you walk arbitrarily far.
|
||||
- **Deterministic**: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism
|
||||
button, stream mode). Same seed + same walk → same residents.
|
||||
- **Leak-free**: after warming ped GPU uploads, **2 full grid walks (~380 chunk build/dispose cycles)
|
||||
→ 0 geometry / 0 texture delta**; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5).
|
||||
- **Budget**: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps
|
||||
headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution
|
||||
is identical to v1's bound.
|
||||
- **Flag-off = v1**: shell with no flag → `streamMode:false`, citizen 0 signature
|
||||
`0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation.
|
||||
|
||||
### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell)
|
||||
Minimum (poll-driven, zero Lane B changes — works today):
|
||||
```js
|
||||
const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null;
|
||||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||||
graph: plan.streets, fleet, chunkStream: streamOpt });
|
||||
```
|
||||
Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the
|
||||
sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**:
|
||||
```js
|
||||
if (streamOpt) {
|
||||
chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41)
|
||||
chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47
|
||||
}
|
||||
```
|
||||
(`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.)
|
||||
Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must
|
||||
not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key
|
||||
(`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours.
|
||||
|
||||
*Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve
|
||||
v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later
|
||||
(needs shop data in the chunk; the sim is graph-only today) — noted, not built.*
|
||||
|
||||
---
|
||||
|
||||
## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
|
||||
|
||||
**All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.**
|
||||
|
||||
### D1 — in-shell rig/keeper verification (`web/index.html`) ✅
|
||||
Verified in the real game (not the test page), via `window.PROCITY`:
|
||||
- **Placeholder→rig upgrade** fires for both peds (`citizens.mode` → `'rig'`, 19 fleet, pool active)
|
||||
and keepers (keeper spawns at the counter `keeperStand` pose as a **rig**).
|
||||
- **Leak-free**: baseline `renderer.info.memory` = {geometries 213, textures 198}; after **12
|
||||
enter/exit shop cycles** it returned to **exactly** 213/198 (Δ geom 0, Δ tex 0). Interiors peak
|
||||
~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's
|
||||
Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose.
|
||||
- **Determinism in-shell**: seed 20261990 → byte-identical identity signature across a full reload.
|
||||
- **`?noassets=1`**: `fleet===null`, mode stays `placeholder`, peds + keeper are placeholder actors,
|
||||
**zero ped GLB fetches** — town fully playable asset-free.
|
||||
|
||||
### D2 — decimated ped validation (critical path — E1 landed `518678d`) ✅ SIGN-OFF FOR F
|
||||
Validated Lane E's decimated fleet (`web/models/peds/`, ref `lane-e/round4-peds`):
|
||||
- **Per-ped tris: 1564–2805, avg 2450 — all ≤3k** (matches E's `_peds_decim.json`).
|
||||
- **Rigs bind + animate**: skinning preserved, the shared `walk.glb`/`idle.glb` retarget unchanged
|
||||
(skeleton/65 joints intact), near actors animate, mixer 0.1–0.3 ms.
|
||||
- **No skinning explosions**: 24-teleport pool-eviction churn → **0 broken meshes**; silhouettes
|
||||
read at street distance; identity variety intact (verified visually — kid/luchador/hi-vis/elder/
|
||||
business all distinct, no melted hands/faces).
|
||||
- **Impostor atlas bakes clean** from the decimated rigs; determinism + leak checks re-pass.
|
||||
- **Gate-3 number for Lane F**: at the 24-rig near cap the fleet now contributes **≈59.6k tris**
|
||||
(was ~1.2M). Test-page whole-view total **88.9k tris** (was ~1.5M). In-shell sample near rig 2729
|
||||
tris, keeper 2073 tris (was 41k). **F is clear to re-measure gate 3** — the fleet leaves ~140k for
|
||||
the town, comfortably ≤200k. (The R1 "peds too heavy" finding is now resolved by E's decimation.)
|
||||
|
||||
### D3 — exposure sync at dusk/night — FOUND + FIXED A REAL BUG ✅
|
||||
Verifying the shell exposed a genuine rendering bug (only correct on my direct-canvas test page):
|
||||
**the impostor material was `toneMapped:false` and applied ACES in-shader**, but the shell renders
|
||||
through an **EffectComposer (RenderPass → bloom → OutputPass)**. three only tone-maps materials when
|
||||
rendering to the **canvas** (`three.module.js:6921–6931`, `currentRenderTarget === null`); into a
|
||||
composer target, materials output **linear** and OutputPass applies ACES once. So my self-tone-mapping
|
||||
impostors were **double-tone-mapped** in the actual game (too dark), matching the rigs only on the
|
||||
test page. **Fix (impostor.js):** the impostor is now a normal `toneMapped:true` material — it outputs
|
||||
**linear** and uses three's own `<tonemapping_fragment>` + `<colorspace_fragment>` call-sites (the
|
||||
`*_pars_*` are auto-injected — including them ourselves double-defines `RRTAndODTFit` and fails to
|
||||
compile), so it tone-maps **identically to the near rigs on both paths**: canvas → ACES per-material;
|
||||
composer → linear → OutputPass ACES. Verified: near rig + mid impostor match brightness at noon AND
|
||||
the scene reads correctly at night 22:00 in the shell (`docs/shots/laneD/r4_shell_*.jpg`), and the
|
||||
test-page canvas path still matches.
|
||||
- **Note for Lane F**: `citizens.setExposure()` is now a **no-op** (exposure is global via
|
||||
`renderer.toneMappingExposure`, which both three and OutputPass read). The call at
|
||||
`web/index.html:245` is harmless — keep or drop it, your call.
|
||||
|
||||
*(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)*
|
||||
|
||||
## What shipped
|
||||
|
||||
| file | role |
|
||||
@ -290,11 +70,10 @@ so the on-screen fps counter reads low (47–84). True cost was measured by timi
|
||||
`CitizenSim` reads `scene.environment` and passes it to the impostor baker.
|
||||
|
||||
4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
|
||||
`ACESFilmicToneMapping`, exposure `/0.6`). **The shell DOES animate `renderer.toneMappingExposure`**
|
||||
(Lane B `lighting.js` sets it per day segment), so mid billboards drift brighter/darker than the near
|
||||
rigs across the day unless matched. **Round-3 fix:** `CitizenSim.setExposure(e)` passthrough added —
|
||||
see the Lane F hook below. (For a non-ACES curve the in-shader ACES in `impostor.js IMP_FRAG` would
|
||||
also need to match; today both are ACESFilmic so exposure is the only variable.)
|
||||
`ACESFilmicToneMapping`, exposure `/0.6`). If Lane B changes the tone-mapping curve or animates
|
||||
`renderer.toneMappingExposure`, call `sim.impostor.setExposure(e)` (and, for a non-ACES curve,
|
||||
the in-shader ACES in `impostor.js IMP_FRAG` would need to match). Mid billboards otherwise drift
|
||||
from the near rigs at the LOD seam.
|
||||
|
||||
*Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed —
|
||||
non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored
|
||||
@ -307,83 +86,14 @@ viewport, missing modelMatrix, frozen exposure.*
|
||||
lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel.
|
||||
- `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day
|
||||
segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame.
|
||||
- **[Round-3] `sim.setExposure(e)` — one line, needed for dusk/night.** The shell already calls
|
||||
`citizens.setTimeOfDay(...)` in the street loop (`index.html:225`); add right after it:
|
||||
```js
|
||||
citizens.setExposure(renderer.toneMappingExposure); // [Lane F] keep mid billboards matched to day/night exposure
|
||||
```
|
||||
Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake
|
||||
(stored internally). **Verified** in `citizens_test.html`: uniform tracks 0.55↔2.3 and persists
|
||||
across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs
|
||||
the near rigs. *(This is a Lane-F seam edit in `index.html`; flagged here per the cross-lane note rule.)*
|
||||
- **[Round-3] Keeper fleet-upgrade (enables F §3.4).** Keepers today are placeholder-only because F
|
||||
builds `new KeeperManager({ camera, citySeed })` with no fleet. To upgrade them to shared GLB rigs,
|
||||
pass the loaded fleet: `new KeeperManager({ camera, citySeed, fleet })`. `keepers.js` already picks a
|
||||
rig when `fleet.ready` and falls back to a placeholder otherwise — no other change. **Dispose is
|
||||
leak-free**: verified 15 enter/exit cycles with a rig keeper → `renderer.info.memory` geo/tex return
|
||||
exactly to baseline (`_disposeInner` frees the clone's skeleton bone-texture only; shared fleet
|
||||
geo/mats are preserved for siblings). Safe to spawn/`remove()` per interior enter/exit.
|
||||
- **Chunk streaming (v1.5):** roster is currently whole-graph — fine for v1, but big towns look sparse
|
||||
(see the design note **"Chunk-streamed roster"** below for the why + the full plan).
|
||||
- **Chunk streaming (TODO for F):** roster is currently whole-graph. The identity fn `identityOf(citySeed,
|
||||
edgeCount, id)` is chunk-ready — key it by `chunkKey+i` per CITY_SPEC and spawn/despawn rosters with
|
||||
Lane B's chunk build/dispose. The LOD + pool machinery is already per-frame and chunk-agnostic.
|
||||
- **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` — feed `x,z,ry` from Lane C's
|
||||
interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose.
|
||||
`keepers.update(dt, playerPos)` each interior frame.
|
||||
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
|
||||
|
||||
## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`)
|
||||
|
||||
*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
|
||||
result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven
|
||||
(`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner
|
||||
despawn, constructor opt-in (`chunkStream`). Kept for the rationale.*
|
||||
|
||||
**The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over
|
||||
*all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test
|
||||
fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N
|
||||
citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a
|
||||
27-node generated town: pop 200 → **4 near / 6 mid / 190 far**). Raising N to fill the near streets
|
||||
wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density.
|
||||
|
||||
**The fix.** Generate + tick citizens **per chunk**, keyed so identity is independent of town size and
|
||||
visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/
|
||||
disposing around the player). Density becomes *constant per unit street*, and total roster is bounded
|
||||
by the live-chunk window, not the town.
|
||||
|
||||
**Design:**
|
||||
|
||||
1. **Chunk-local identity.** Replace the global running `id` with a per-chunk key:
|
||||
`identityOf(citySeed, chunkKey, i)` for `i in [0, perChunkCount)`, and choose the citizen's home
|
||||
edge from **that chunk's** edges (`chunkIndex(plan).chunks[chunkKey].edges`), not the global list.
|
||||
Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks
|
||||
were visited — the determinism property that whole-graph keying quietly loses at scale. `perChunkCount`
|
||||
is seeded per chunk and scaled by district (main-street chunks busier than residential) × `densityAt(tod)`.
|
||||
2. **Windowing — poll-driven (works today, no Lane B change).** Each frame derive the active chunk set
|
||||
from the camera: `chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋)` + neighbours within a radius R (R≥1
|
||||
so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set →
|
||||
build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and
|
||||
needs nothing from B. *Alternative — hook-driven:* `chunks.js` exposes an optional `ctx.onChunkBuilt`
|
||||
(see `chunks.js:40` — "inert unless a consumer sets it"); if B also adds `onChunkDisposed`, drive
|
||||
spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then.
|
||||
3. **Storage.** Swap the single `this.roster` array for `this.chunkRosters = Map<chunkKey, Citizen[]>`.
|
||||
The per-frame `update()` iterates the union of live-chunk citizens. **Everything downstream is
|
||||
unchanged** — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate
|
||||
per-citizen and are chunk-agnostic; the near-cap + mixer budget stay **global** across live chunks
|
||||
(nearest-first selection already does the right thing on the merged set).
|
||||
4. **Ownership + hand-off.** A citizen is owned by its spawn chunk. It walks freely (the graph is
|
||||
continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns
|
||||
only when **its owner chunk** unloads — even if it's momentarily standing in a still-loaded neighbour
|
||||
(acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn.
|
||||
5. **Budget.** Live window ≈ (2R+1)² chunks × `perChunkCount`. Tune `perChunkCount` (~15–30) so the
|
||||
near streets reach the 24-cap while total roster stays ~150–270 — similar cost to today's flat 200,
|
||||
but concentrated where the camera is instead of smeared across the map.
|
||||
|
||||
**Migration.** Opt-in and back-compatible: `new CitizenSim({ …, chunkStream: { chunkIndex, chunkSize,
|
||||
radius } })` switches on chunk-keyed identity + windowing; omit it and the current whole-graph roster is
|
||||
unchanged. So v1 ships as-is and v1.5 flips a constructor option — no rewrite of the hot path.
|
||||
|
||||
**Explicitly NOT in scope here:** cross-chunk social groups, per-district behaviour trees, persistent
|
||||
citizens (a citizen you saw yesterday) — all v2 (`docs/V2_IDEAS.md`).
|
||||
|
||||
## Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)
|
||||
|
||||
Only `walk` + `idle` are wired today (they ship with the ped fleet). CITY_SPEC says
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
# LANE E — cross-lane notes
|
||||
|
||||
## Round 7
|
||||
|
||||
**→ Lane D (D1 — the flip gate): merged peds ready to validate.** `web/models/peds/*.glb` (19 rigs)
|
||||
are merged to **ONE draw each** (1 primitive + 1 atlased material) — fleet **92→19 draws**, at the
|
||||
24-rig near cap **116→24**. Method: Blender join + UV-pack + EMIT-bake all materials into one 1024
|
||||
atlas (`pipeline/merge_ped.py`). **Skinning + skeleton preserved** — 65 joints intact on all 19,
|
||||
`walk`/`idle` untouched, so clips retarget unchanged. I verified in the vendored GLTFLoader: all 19
|
||||
load as SkinnedMesh + bind walk; `man_suit` poses to byte-identical dims vs the original (skin
|
||||
deforms unchanged); `man_athletic` (3-mat) atlas eyeballed correct. Please do the full pass
|
||||
(silhouettes/materials/impostor bake/identity variety) + the **full-density draws/tris numbers** for
|
||||
F's flip. Pre-merge peds backed up (gitignored) at `pipeline/.peds_r6/` if you need a diff; per-ped
|
||||
in `pipeline/_peds_merge.json`. **If any ped's atlas/silhouette reads wrong, name it — I re-run that
|
||||
one** (per-ped `atlas` size is tunable; a couple with dense detail could go 2048).
|
||||
|
||||
**→ Lane C (E2 — stock pack): pipeline built + PROVEN, sample staged for you now.**
|
||||
`pipeline/build_stock_pack.py` produces the pack (parody-transform → atlas WebPs + JSON index). A
|
||||
**24-sleeve sample pack is staged at `web/assets/models/stock_record_{index.json,atlas_00.webp}`** —
|
||||
wire `?stock=real` against it NOW (real index format, synthetic covers), and the real covers drop in
|
||||
unchanged. **Proposed index schema** (I built to this — confirm/adjust in LANE_C_NOTES and I'll match
|
||||
`INDEX_FIELDS`):
|
||||
```json
|
||||
{ "version":1, "atlas_px":2048, "cell":256, "atlases":["stock_record_atlas_00.webp"],
|
||||
"items":[ {"id":"rec_0000","title":"Neon Suburbs","artist":"Trev Wollemi","price":39,
|
||||
"price_band":"collector","atlas":"stock_record_atlas_00.webp","uv":[0,0,0.125,0.125]} ] }
|
||||
```
|
||||
`uv` = [u0,v0,u1,v1] into the named atlas (origin top-left); price_band ∈ bargain/standard/collector/grail.
|
||||
Seeded pick per bin is yours (same shop.seed → same crate). Fail-soft: pack missing → parody canvas.
|
||||
|
||||
**→ Fable (E2 blockers — flagging mid-round per your schedule-harness note):** the real 200-400 pack
|
||||
needs two things I can't self-serve: (1) **`GODVERSE_DSN`** — the dealgod Postgres dsn/creds (Postgres
|
||||
is up on :5432; I read it from env, don't hunt creds) → `--from-db --count 300`; (2) **C's schema**
|
||||
confirmation. Both external. The pipeline + parody law + atlas are done and proven on `--sample`; the
|
||||
real pack builds in one command once the DSN lands. Not blocking C's consumer wiring (sample staged).
|
||||
|
||||
## Round 6
|
||||
|
||||
**E1 done** — `publish.py` now merges into `_published.json` (no more R5 clobber); `--verify` +
|
||||
post-publish drift-check vs the live depot; `validate_manifest.py` fails if a manifest GLB isn't in
|
||||
`_published.json`. Record verified 27==depot. All 27 procity GLBs stay live + recorded.
|
||||
|
||||
**→ Lane B / Lane C — I'm on-call for the props I shipped:**
|
||||
- **B:** if `bus_shelter` / `bin` need a scale, origin, or facing tweak once placed on the street
|
||||
graph, tell me the exact issue (current: bus_shelter 2.33×1.41 m / 2.4 m, bin 0.57×0.68 m / 1.0 m,
|
||||
both base-origin minY=0). I can re-normalize + re-publish same-name (idempotent, merge-safe now).
|
||||
- **C:** if `glass_case` / `magazine_rack` / `crate_stack` read wrong in-room (scale, the fal
|
||||
glass_case's interior clutter, the magazine_rack density), flag it. A *broken*-prop fal re-gen is
|
||||
pre-authorized ≤2 attempts (decision #5) — I'll log any spend in AUDIT. Footprints in the manifest;
|
||||
all base-origin. Mapping guidance for the 3 orphans is in the Round-5 → Lane C section below.
|
||||
|
||||
## Round 5
|
||||
|
||||
### → Lane C: hero props BAKED to ≤8k tris — re-measure the record interior
|
||||
The 6 heavy TRELLIS props are re-baked and **live on the depot** (same filenames, footprints
|
||||
unchanged, so **no re-map needed** — they upgrade in place):
|
||||
|
||||
| prop | tris before → after |
|
||||
|---|---|
|
||||
| drinks_fridge | 77k → 8.0k |
|
||||
| milkshake_mixer | 73k → 8.0k |
|
||||
| listening_booth | 54k → 8.0k |
|
||||
| counter_till | 32k → 8.0k |
|
||||
| novelty_record | 26k → 8.0k |
|
||||
| arcade_cabinet | 25k → 8.0k |
|
||||
|
||||
**Fleet 288k → 48k tris.** Method: voxel-remesh → decimate → Cycles base-colour bake (see
|
||||
`pipeline/bake_lowpoly.py`, AUDIT R5). Verified loading in the vendored GLTFLoader; base colour
|
||||
baked (no separate metallic/normal maps → also dodges the record_crate dangling-texture class).
|
||||
→ **Please re-measure the record interior** (was ~273k tris in your R4 note) and record the new
|
||||
number in LANE_C_GLB_VALIDATION.md — it should drop by ~most of the prop weight.
|
||||
|
||||
### → Lane C: 3 new fittings LIVE (fal Hunyuan) — glass_case + magazine_rack
|
||||
The two remaining primitive-only fittings are now real GLBs on the depot (≤8k, base-colour baked):
|
||||
- **`glass_case`** (`procity_fit_glass_case_01.glb`, 1.08×1.42 m, 1.0 m tall) — timber base + glass
|
||||
panels + items inside. Registry ids `glass_case` (toy/dept) + `glass_cabinets` (pawn) both alias
|
||||
to it. → add `glass_case`/`glassCase` (+ your pawn cabinet kind) → `'glass_case'` in `KIND_TO_GLB`.
|
||||
- **`magazine_rack`** (`procity_fit_magazine_rack_01.glb`, 0.54×0.44 m, 1.2 m tall) — tiered timber
|
||||
rack w/ magazines (milkbar/pawn). Registry `magazine_rack` aliases to it. → map your kind → `'magazine_rack'`.
|
||||
- Both base-origin, verified loading in the vendored GLTFLoader. `manifest.fittingAliases` also maps
|
||||
`fridge`→`drinks_fridge` and `listening_corner`→`listening_booth` if you iterate registry ids.
|
||||
|
||||
### → Lane B: new street props — `bus_shelter` + `bin`
|
||||
- `procity_street_bus_shelter_01.glb` (`furniture.bus_shelter`, 2.33×1.41 m, 2.4 m, ≤8k, base-origin).
|
||||
Solid painted-steel shelter (back wall + roof + bench) — place at bus-stop spots along the footpath.
|
||||
- `procity_street_bin_01.glb` (`furniture.bin`, 0.57×0.68 m, 1.0 m, 3k) — red council wheelie bin, an
|
||||
obvious town-furniture gap. Map `bin` in your furniture GLB-upgrade path if you want it on footpaths.
|
||||
- **B3 resolved:** `novelty_record` is now **8k** (was 26.5k) via the E1 bake — re-measure the busy view.
|
||||
All furniture GLBs base-origin + footprint-accurate; fail-soft as usual.
|
||||
|
||||
### → Lane C also: `crate_stack` for floor piles / dig
|
||||
`procity_fit_crate_stack_01.glb` (`fittings.crate_stack`, 0.71×0.72 m, 0.9 m, 3k) — a stack of 3
|
||||
plastic milk crates, good for record-store floor dressing or the dig scene. Optional; map a kind to
|
||||
`'crate_stack'` if useful.
|
||||
|
||||
### → Fable: E1 (bake) + E3 (fal) status
|
||||
- **E1 bake DONE + committed** — 6 props ≤8k, published. Feeds C's re-measure + your v1.1 tag.
|
||||
- **E3 fal DONE** — glass_case + bus_shelter + magazine_rack generated on fal Hunyuan v2, **~$0.64
|
||||
total** (log in AUDIT R5), ≤2 attempts each, all baked ≤8k + published. Guardrail held (nothing
|
||||
else on fal). `validate --depot` 0 err. Last two primitive-only fittings retired.
|
||||
- **E2 depot growth** next (v2-serving library props).
|
||||
|
||||
---
|
||||
|
||||
## Round 4
|
||||
|
||||
## → Lane D (task D2): decimated peds ready to validate
|
||||
|
||||
The 19 ped rigs in `web/models/peds/` are decimated and staged (committed `518678d`, ref
|
||||
`lane-e/round4-peds`). Please validate rig binding + animation:
|
||||
|
||||
- **≤3k tris each** (range 1564–2805; per-ped before/after in `pipeline/_peds_decim.json`).
|
||||
Fleet total **924k → 46.5k tris** (48.6k → 2.45k avg). At the 24-rig near cap ≈ **≤67k**.
|
||||
- **Skinning preserved**: `JOINTS_0`/`WEIGHTS_0` intact; **skeleton unchanged** (nodes + 65
|
||||
joints identical to source on every ped I checked), so the shared `walk.glb`/`idle.glb`
|
||||
clips retarget unchanged. **`walk.glb`/`idle.glb` were NOT touched.**
|
||||
- Method: `gltf-transform weld` → `simplify --ratio 0.05 --error 0.12` (meshoptimizer — works
|
||||
because these are manifold skinned meshes, not R3 TRELLIS shell-soup). Contact sheet:
|
||||
`docs/shots/laneE/peds_decimated.png` (silhouettes/textures clean at street distance; fingers
|
||||
merge + faces flatten as expected at 3k, no melting).
|
||||
- Originals backed up (gitignored) at `pipeline/.peds_orig/` if you need a before/after diff.
|
||||
|
||||
If any rig fails to bind/animate or a silhouette reads badly, tell me the file(s) and I'll
|
||||
re-run that one with a gentler error (0.08 keeps ~50% more tris but still <5k). Then F re-measures
|
||||
gate 3 after your sign-off.
|
||||
|
||||
## → Lane C: E2 asset re-exports
|
||||
|
||||
1. **`procity_fit_record_crate_01.glb` — FIXED.** Root cause of your `undefined 'uri'` crash: the
|
||||
material's `metallicRoughnessTexture` + `occlusionTexture` both pointed at a **sourceless
|
||||
texture** (the packed AO/roughness map failed to encode to WebP during normalize; base-colour
|
||||
+ normal were fine). Fix: `pipeline/fix_glb_textures.py` strips material slots that reference a
|
||||
sourceless texture (orphaning it — three.js loads textures lazily, so no crash). **Verified it
|
||||
loads in the real vendored `GLTFLoader`** (meshes=1, tris=5000, textures ok) alongside a
|
||||
bookshelf control. Re-published to the depot — re-map `record_crate`/`recordBin` in `glb.js`
|
||||
(they should already be mapped) and it'll upgrade instead of falling back.
|
||||
2. **counter-with-till — LIVE.** New fitting `counter_till` (`procity_fit_counter_till_01.glb`):
|
||||
**1.61 × 0.64 m footprint, 1.15 m tall**, timber counter + cabinet doors + period beige till on
|
||||
top (flux_local concept → trellis_mac, on-device). Fixes the R3 4 m till-less counter that
|
||||
squashed. **Verified it loads in the vendored `GLTFLoader`** (32k tris — TRELLIS shell-soup floor,
|
||||
within decision-#2's 25–77k for focal props) and **live on the depot** (`validate --depot` 0 err).
|
||||
→ **In `glb.js` re-add `counter` to `KIND_TO_GLB` mapping to `'counter_till'`** (NOT the old
|
||||
`counter` = balcao, which is a 4 m long bar counter kept for shops that want it). The manifest
|
||||
`fittingAliases.counter` → `counter_till` too. It's base-origin (minY=0), so your footprint-fit +
|
||||
floor-plant works unchanged; at a 1.4–2.0 m counter slot it scales to a believable ~1.0–1.4 m
|
||||
tall counter.
|
||||
|
||||
## → Fable: status
|
||||
|
||||
- **E1 (gate-3 critical path): DONE.** Peds ≤3k, committed + pinned. Unblocks D2 → F3.
|
||||
- **E2a record_crate: DONE + verified** (loads in vendored GLTFLoader). Re-published.
|
||||
- **E2b counter: in progress** (local, on-device — no fal spend per decision #3).
|
||||
- Not doing: fal.ai spend (glass-case/bus-shelter stay parked), hero-prop bake-to-lowpoly.
|
||||
@ -176,34 +176,14 @@ LOD off camera distance (not chunk-driven — chunks.js has no lifecycle hook).
|
||||
- **Note for §3.4:** placeholder near-tier is the draw cost; the **rig-fleet upgrade** (shared meshes +
|
||||
impostor promotion) is what lets `?pop=` rise well past 140. Until then 140 is the safe lively default.
|
||||
|
||||
### 3.4 Manifest + `?noassets` + rig-fleet upgrade ✅ DONE (R3, verified live)
|
||||
Wired in the shell (all gated by `NOASSETS = ?noassets=1`):
|
||||
- **Rig fleet:** `loadPedFleet('models/peds/')` (19 char GLBs + walk/idle clips exist locally) → passed
|
||||
to `new CitizenSim({…,fleet})` and (via `createInteriorMode({…,fleet})`) to `KeeperManager`. Peds +
|
||||
keeper **upgrade placeholder→shared GLB rigs** once `fleet.whenReady` resolves (verified:
|
||||
`citizens.mode==='rig'`, `keeperKind==='rig'`, detailed characters on the street + behind counters).
|
||||
- **Interior GLB:** `preloadManifest()` at boot + `buildInterior(shop,THREE,{useGLB:!NOASSETS})` →
|
||||
Lane C `glb.js` swaps fitting primitives for **depot GLBs** (`depot:<file>` → 3GOD, reachable).
|
||||
Verified: bookshop fittings load real GLBs (+8 meshes, dense shelves). *Not all kinds map* — `counter`
|
||||
is intentionally primitive; `record_crate` is a known-failing GLB → fail-soft to primitive.
|
||||
- **`?noassets=1`:** `fleet=null`, manifest not preloaded, `useGLB=false`. Verified **zero network**
|
||||
(Performance API: 0 GLB, 0 depot, 0 manifest, 0 ped-model requests); 100% primitive, still legible.
|
||||
- **Leak-free:** rig keeper + interior GLBs dispose clean — the persistent geometry is the intentional
|
||||
**shared `loadGLB` cache** (cold entry +N geo, re-entries flat: proven cycle2/3 = 189→189). soak.py
|
||||
pre-warms this cache so the gate is honest.
|
||||
- **⚠ finding (D/E):** GLB **character models are triangle-heavy** (~45k each near rig) → busiest view
|
||||
tris ~279k, soak peak ~1.0M, over the **200k** soft budget. Draws are fine (~200/300). **Decimate the
|
||||
ped GLBs** (and heavy fittings) to hit the tri budget; **F holds `?pop=140`** until then (raising pop
|
||||
adds near rigs = more tris). See Cross-lane requests §9.
|
||||
|
||||
### 3.5 Hours / closed shops ✅ DONE (R3, verified live)
|
||||
- `currentHour()` parses `lighting.getClock().hour` (`'HH:MM'`→float); `isOpen(shop)` = `open ≤ H < close`.
|
||||
Both exposed on `window.PROCITY`. **`enterShop` gates on it**: closed → `🔒 CLOSED · opens HH:00`, no build.
|
||||
- Lane A ships exactly one **`openLate:true`** landmark/town — always the **video rental** (Video Regal
|
||||
`[11,23]` etc.). Verified: **midday** toy shop `[10,16]` enters; **night (22:00)** toy shop blocked,
|
||||
**only the video shop opens** (the night destination).
|
||||
- **Cross-lane (B):** the facade *visual* closed-state (dark windows + CLOSED plate) + a "CLOSED" door
|
||||
tooltip are Lane B's rendering — `window.PROCITY.isOpen(shop)` is the predicate. See §9.
|
||||
### 3.4 Point B/C at E's manifest ⏳
|
||||
- Shell loads `web/assets/manifest.json` if present → `window.PROCITY.manifest`. B/C prefer manifest
|
||||
GLB upgrades over primitives; **registry.js (Lane A) stays the type→skin source of truth**, the
|
||||
manifest is the *GLB-upgrade + validated-skin* layer.
|
||||
- `?noassets=1`: shell sets `window.PROCITY.noassets = true`; manifest load is skipped and every
|
||||
loader call is short-circuited to the primitive/flat-colour fallback. (F defines the flag; B/C/E
|
||||
check `window.PROCITY.noassets` before touching `loaders.js`.)
|
||||
- **Gate:** asset-free — `?noassets=1` full playthrough, zero crashes, town still legible.
|
||||
|
||||
### 3.5 Hours / closed shops (A data → B facade + C gate) ⏳
|
||||
- `isOpen(shop, seg)` from `shop.hours:[open,close]` vs the current day segment (map 6 segments →
|
||||
@ -214,29 +194,28 @@ Wired in the shell (all gated by `NOASSETS = ?noassets=1`):
|
||||
|
||||
---
|
||||
|
||||
## 4. QA gates — ALL GREEN ✅ (v1.0 criterion met, R4). `tools/qa.sh --strict` is the static umbrella.
|
||||
## 4. QA gates (all green ⇒ v1). `tools/qa.sh --strict` is the umbrella.
|
||||
|
||||
Measured against the fully-committed R4 tree (seed 20261990, "Boolarra Heads"), verified live +
|
||||
by a 6-auditor adversarial v1-readiness workflow (2026-07-14) → **GO**.
|
||||
| # | gate | how | tool | state |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Determinism** | seed 1234 → identical map PNG + identical first-interior placement list, 2 runs/machines | `scaffold_check.mjs` (plan) + `shots.py --seed 1234` (map PNG) + interior placement hash | ⏳ needs A,B,C |
|
||||
| 2 | **Soak** | 10-min scripted walk, ≥30 chunks, ≥15 shops; `renderer.info.memory` geo/tex back to baseline after each interior; JS heap stable; **0** console errors | `tools/soak.py` | ⏳ needs B,C |
|
||||
| 3 | **Budget** | busiest intersection, midday: HUD ≤300 draws, ≤200k tris | read HUD via `soak.py`/browser | ⏳ needs B |
|
||||
| 4 | **Asset-free** | `?noassets=1` full playthrough, zero crashes | `soak.py --noassets` | ⏳ needs B |
|
||||
| 5 | **Selfchecks** | `node web/js/citygen/selfcheck.js` + `python3 pipeline/validate_manifest.py` green | `tools/qa.sh` | ✅ runner ready; ⏳ inputs |
|
||||
| 6 | **Shots** | refresh `docs/shots/*`; add `docs/shots/v1_tour/` (10 beauty shots) | `tools/shots.py` | ⏳ needs B,C |
|
||||
|
||||
| # | gate | criterion | result (R4) |
|
||||
|---|---|---|---|
|
||||
| 1 | **Determinism** | seed → identical plan (deep-equal + golden fingerprint) + selfcheck | ✅ scaffold_check green; selfcheck **1301/1301**, fingerprint **0x3fa36874** (intended A2 shift, not drift); two node processes → identical `sha256(plan)` per seed; chunkIndex covers every lot |
|
||||
| 2 | **Soak** | walk ≥30 chunks, ≥15 shops; geo/tex baseline after each interior; heap stable; **0** console errors | ✅ **37 chunks, 20 shops, heap 38→38 MB, 0 errors, leak-free** |
|
||||
| 3 | **Budget** | busiest intersection: ≤300 draws, ≤200k tris (settled clean measure) | ✅ **draws 138 · tris ~31k** (baseline 372k; peds decimated 924k→46k ≤3k each, D2 sign-off) |
|
||||
| 4 | **Asset-free** | `?noassets=1` full playthrough, zero crashes, zero network | ✅ **heap 13→13 MB, 0 errors, 0 asset requests** |
|
||||
| 5 | **Selfchecks** | `selfcheck.js` + `validate_manifest.py --depot` green via qa.sh | ✅ **qa.sh --strict 4/4**; manifest `--depot`: **23 GLBs live, 0 err** |
|
||||
| 6 | **Shots** | `docs/shots/v1_tour/` 10 beauty shots + contact sheet | ✅ **10 full-frame 1280×720, no letterbox**, 3 new poses resolve; incl. §3.5 **night closed-facade** (CLOSED plates + lit openLate video) + real **record/opshop interiors** (keeper + hero props) |
|
||||
|
||||
> Note: soak peak-draw/tri *warnings* are transient teleport/impostor-atlas-bake sampling spikes;
|
||||
> gate 3's settled busiest-intersection measure is the authoritative budget check (draws 138 / tris 31k).
|
||||
|
||||
### DBG hook — LANDED (Lane B `web/js/world/dbg.js`, `?dbg=1`)
|
||||
`window.DBG` exposes `ready / shot(name) / teleport(x,z,ry) / setSegment(seg) / enterShop(id) /
|
||||
exitShop() / info()` and drives `tools/shots.py` + `tools/soak.py` (mirrors 90sDJsim's `window.DBG`).
|
||||
Bookmarks: street_noon, arcade, market_square, milkbar_dusk, night_neon, crossroads_busy,
|
||||
residential_collar, warehouse_fringe. **Interior tour shots** (record/opshop) are captured by
|
||||
`enterShop(id)` directly (not via `shot()`, which forces street mode) — see `tools/shots.py`.
|
||||
### DBG hook F requests from Lane B (for gates 1–4,6)
|
||||
The house shot/soak harnesses drive the game through a debug object. **Ask Lane B to expose**, on
|
||||
`window.DBG` (guarded, only when `?dbg=1`):
|
||||
- `DBG.ready` → true once first chunks built + assets settled (soak/shots wait on it).
|
||||
- `DBG.shot(name)` → snap camera to a named bookmark (`street_noon`, `arcade`, `market_square`,
|
||||
`milkbar_dusk`, `night_neon`, …); returns after the frame settles.
|
||||
- `DBG.teleport(x,z,ry)` and `DBG.setSegment(seg)` → drive the soak walk + time of day.
|
||||
- `DBG.enterShop(shopId)` / `DBG.exitShop()` → scripted interior visits (shot `record_interior`).
|
||||
- `DBG.info()` → `{drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode}` (budget/soak read this).
|
||||
This mirrors 90sDJsim's `window.DBG.ST` / `window.DBG.shot()` (`90sDJsim/tools/shots.py:31-33`).
|
||||
Until it exists, `shots.py`/`soak.py` degrade to booting the default view and warn.
|
||||
|
||||
---
|
||||
|
||||
@ -246,10 +225,9 @@ residential_collar, warehouse_fringe. **Interior tour shots** (record/opshop) ar
|
||||
2. ✅ Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live).
|
||||
3. ✅ Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types.
|
||||
4. ✅ Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe.
|
||||
5. ✅ Wire §3.4 (manifest + `?noassets` + rig-fleet) and §3.5 (hours) — all verified live (R3).
|
||||
6. ◑ Gates: 2 & 4 **PASS** (soak/asset-free), 5 **GREEN** (`--depot`), 3 tris-blocked on D/E decimation,
|
||||
6 shots need Lane B bookmark poses. `docs/shots/v1_tour/` captured.
|
||||
7. ⏳ `tools/qa.sh --strict` green + gate 3 tris ⇒ tag v1 (pending D/E ped-GLB decimation).
|
||||
5. ⏳ Wire §3.4 (manifest + `?noassets`) — also the rig-fleet upgrade. Then §3.5 (hours). Commit.
|
||||
6. ⏳ Run gates 1–6; capture `docs/shots/v1_tour/`; write final numbers into `F-progress.md`.
|
||||
7. ⏳ `tools/qa.sh --strict` green ⇒ tag v1.
|
||||
|
||||
## 7. Live smoke-test findings (2026-07-14, real generated town, seed 20261990)
|
||||
|
||||
@ -277,71 +255,3 @@ Booted the wired game in a browser and drove the camera via `window.PROCITY`. St
|
||||
- **C:** does `places` tag the counter slot with a stable `kind:'counter'` so keepers find it?
|
||||
- **E:** is `?noassets` honored inside your manifest-consuming code, or does F gate it at the shell?
|
||||
- **D:** roster spawn/despawn API name + does it accept a chunk key directly?
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-lane requests (Lane F → others, R3) — the integration surfaced these; not silent-editing your files
|
||||
|
||||
**→ Lane D / Lane E — decimate the ped GLBs (blocks gate 3 tris).** The rig fleet (`web/models/peds/*.glb`)
|
||||
is wired and works, but the character models are triangle-heavy: **~45k tris each near rig**, so the
|
||||
busiest-intersection view hits **~279k tris** (soak peak ~1.0M) vs the **200k** CITY_SPEC budget. Draws are
|
||||
fine (~200/300 — the shared-mesh sharing worked). Please **decimate/LOD the 19 ped GLBs** (target ≤~10–15k
|
||||
tris each) so gate 3 passes; then F can raise `?pop=` past 140. Same applies to any heavy interior fitting GLB.
|
||||
|
||||
**→ Lane B — three dbg.js / shell items:**
|
||||
1. **shots.py bookmark poses (gate 6).** `dbg.js BOOKMARKS` defines `street_noon/arcade/market_square/
|
||||
milkbar_dusk/night_neon`, but F's v1_tour also uses `warehouse_fringe / residential_collar /
|
||||
crossroads_busy` — these fall back to `street_noon` (identical frames). Please add those 3 poses.
|
||||
2. **shots letterbox.** In headless (1280×720), `DBG.shot()`→`composer.render()` renders into a square
|
||||
sub-region even though renderer/canvas/camera are correctly sized — the EffectComposer render targets
|
||||
don't rebuild on the initial resize. A real viewport toggle in shots.py didn't fix it; likely the
|
||||
composer needs an explicit `setSize`/reset on first `shot()`. (In-game, real window sizes render fine.)
|
||||
3. **§3.5 closed-facade visual.** F gates *enterability* by hours (`window.PROCITY.isOpen(shop)`); the
|
||||
*visual* closed state — dark windows + a CLOSED plate, and a "CLOSED" door tooltip in `hud.js` — is
|
||||
your facade/HUD render. Predicate is ready on `window.PROCITY`.
|
||||
|
||||
**Note:** F-owned harness bugs found + fixed this round (so the gates actually run): `shots.py`/`soak.py`
|
||||
polled `window.DBG` before its async install (race → false "absent"); read `DBG.plan` (unexposed → use
|
||||
`window.PROCITY.plan`); `enterShop({shopId})` vs the bare-id contract; `shop.lot` treated as an object
|
||||
(it's a lot **id**); soak now pins midday (else §3.5 closes shops mid-walk) + pre-warms the shared GLB
|
||||
cache (else the one-time upload reads as a leak) + budget peaks demoted to warnings (DBG.info samples spike
|
||||
during streaming; gate 3's clean single-pass is authoritative).
|
||||
|
||||
---
|
||||
|
||||
## 10. Round 5 — v1.1 close-out + v2 flag seams (Lane F tracking)
|
||||
|
||||
Round 5 (v1.1 polish + v2 foundations behind flags). **v2 prime law:** default boot stays
|
||||
v1-identical; `GOLDEN.hash 0x3fa36874` must not move; `qa.sh --strict` green at every commit.
|
||||
|
||||
### v1.1 tag — ⏳ WATCH (blocked on C1 + E1)
|
||||
Tag `v1.1` once **Lane C** re-maps `counter_till` + `record_crate` (C1) and **Lane E** bakes the
|
||||
hero props ≤8k (E1). F then: verify record/milkbar/opshop interiors (GLB counter+till correct, bins
|
||||
upgraded, baked props look right), re-capture ONLY the 2 interior tour shots if improved, qa green →
|
||||
`git tag v1.1` (local). *Nothing to tag yet — HEAD `039c179`, no round-5 lane commits.*
|
||||
|
||||
### v2 flag seams — table (F wires `web/index.html` + `tools/` as lanes document them)
|
||||
Every flag **default-off + independent**. Wire only from the owning lane's NOTES call-site spec.
|
||||
|
||||
| flag | owner | what | F wiring | state |
|
||||
|---|---|---|---|---|
|
||||
| `?plansrc=osm` | A | second plan source (OSM fixture) behind the CityPlan contract | select in shell plan bootstrap | ❌ **A did not run R5** — task carries to R6 |
|
||||
| `?winmap=1` | B | parallax interior-mapping window shader | none needed (self-contained in `buildings.js`) | ✅ landed `2a923b6` |
|
||||
| `?dig=1` | C | dig.js crate-riffle on procedural stock | wired by F (`26d68cd`): `DIG_ON` → `interiorMode`, Esc/pointer-lock guards | ✅ landed `7131f53` + F2 |
|
||||
| `?roster=stream` | D | chunk-streamed citizen roster | none needed (self-contained in `sim.js`) | ✅ landed `93a3168` |
|
||||
|
||||
*(table updated by Fable in the R5 review — F3 harness extensions below did NOT land in R5; they
|
||||
are the top Lane F item for R6.)*
|
||||
|
||||
### v2 QA harness extensions (F3) — build as flags land
|
||||
- **Determinism gate:** pin `plan-hash` + `first-interior-hash` per `(seed, plansrc)`. Synthetic
|
||||
stays `0x3fa36874`; add the osm-fixture hash when A lands.
|
||||
- **Flags-off regression check:** boot no-flags → assert plan hash + a settled draws/tris snapshot
|
||||
within tolerance of the v1 baseline (catches a v2 flag leaking into the default path). *This is the
|
||||
enforcement arm of the v2 prime law — build early, before flags land.*
|
||||
- **v2 smoke:** boot each flag on, walk 2 chunks, enter 1 interior (+1 dig if C landed), 0 console
|
||||
errors. Wire into `qa.sh` as a **non-strict (warn) gate** this round; strict next round.
|
||||
|
||||
### Marshal
|
||||
Shared-tree arbiter this round: F owns `web/index.html` + `tools/`; if two lanes collide on a file,
|
||||
F arbitrates. Cross-lane asks → the owning lane's NOTES, not silent edits.
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
# PROCITY — Round 3 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND5_INSTRUCTIONS.md](ROUND5_INSTRUCTIONS.md) (2026-07-14).** Rounds 3–4
|
||||
> are complete — v1.0 tagged. Historical reference only; do not action.
|
||||
|
||||
Date: 2026-07-14 · Written after all six lanes finished round 2 and Lane F's first two
|
||||
integration passes landed. Repo state as of these instructions: **everything committed on
|
||||
`main`**, `tools/qa.sh --strict` **GREEN** (4/4 gates, selfcheck 1283/1283, manifest 0 errors).
|
||||
|
||||
@ -1,235 +0,0 @@
|
||||
# PROCITY — Round 4 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND5_INSTRUCTIONS.md](ROUND5_INSTRUCTIONS.md) (2026-07-14).** Round 4 is
|
||||
> complete — v1.0 tagged (`2698b40`, all 6 gates green). Historical reference only; do not action.
|
||||
|
||||
Date: 2026-07-14 · Written after all six lanes finished round 3. You are an Opus 4.8 agent
|
||||
assigned ONE lane (read your session prompt). Read this file, your `docs/LANES/LANE_<X>_*.md`
|
||||
spec, your `<X>-progress.md`, and any `LANE_*_NOTES.md` addressed to you before touching code.
|
||||
|
||||
Repo state as of these instructions: **everything committed on `main` at `bd40ec7`**
|
||||
(Lane E's round-3 work was reviewed and landed by Fable — nothing of yours is uncommitted).
|
||||
`tools/qa.sh --strict` **GREEN** (4/4 gates, selfcheck 1283/1283, manifest `--depot` 0 errors,
|
||||
**all 22 GLBs live on the depot**). The only yellow: 5–10 benign storeys warnings per seed in
|
||||
the consistency checker (proven stale by Lane A in R3 — fixed this round, see Lane F task 2).
|
||||
|
||||
## Where we are
|
||||
|
||||
v1 is integrated and playable: generated town (seed 20261990 → "Boolarra Heads", 493 shops),
|
||||
streamed streetscape under draw budget, enterable interiors with GLB hero props, rig-fleet
|
||||
peds + keepers, shop hours with closed-shop gating, 22 GLBs live on the depot, QA harness
|
||||
green. **Round 4 is the v1-tag round.** Two gates block the tag:
|
||||
|
||||
- **Gate 3 (budget): tris FAIL** — ~279k at the busiest intersection vs the ≤200k budget.
|
||||
Cause: the 20 ped rig GLBs in `web/models/peds/` are ~1.1–2.5 MB (~45k tris) each.
|
||||
→ Lane E decimates, Lane D validates, Lane F re-measures. This is the critical path.
|
||||
- **Gate 6 (shots): letterbox + poses** — `DBG.shot()` headless-composer letterbox bug +
|
||||
3 missing bookmark poses. → Lane B fixes, Lane F re-captures the v1 tour.
|
||||
|
||||
Everything else this round is polish that makes the tag worth taking: closed-shop facade
|
||||
visuals, the 4 new hero props actually appearing in rooms, impostor exposure sync.
|
||||
|
||||
## Integrator decisions (so nobody blocks on them)
|
||||
|
||||
1. **Ped decimation target: ≤3k tris per ped rig.** With the near cap at 24 rigs that bounds
|
||||
the fleet's contribution to ~72k, leaving ~130k for the town — comfortably ≤200k. Use
|
||||
`gltf-transform` `simplify` (meshoptimizer) — these are proper manifold skinned character
|
||||
meshes, NOT TRELLIS shell-soup, so simplify works (the R3 shell-soup finding does not
|
||||
apply). Preserve skinning weights + the separate `walk.glb`/`idle.glb` animation clips.
|
||||
2. **Hero-prop tris stay as-is for v1** (25–77k). They are focal, one-per-room, and gate 3
|
||||
measures the street. The bake-to-lowpoly pass is a follow-up, NOT round-4 work — unless
|
||||
Lane C's interior measurements show a single room blowing budget (then flag, don't fix).
|
||||
3. **fal.ai fallback for glass-case + bus-shelter (~$2.70) stays parked.** Do NOT spend it.
|
||||
Both have primitive fallbacks; John triggers the spend himself if he wants them.
|
||||
4. **The depot is live and canonical** — `validate --depot` green, publish is done. Lane C:
|
||||
validate GLBs through the normal `depot:` path (or `?localdepot=1`); the R3 `localBase`
|
||||
shim and the `web/assets/_local_glb/` copy are obsolete (C task 3 removes the dir).
|
||||
5. **v1 tag criteria**: all 6 gates green in §4 of LANE_F_NOTES → Lane F tags `v1.0` on main.
|
||||
Nothing is pushed anywhere — tagging is local, John pushes.
|
||||
|
||||
## Process rules (unchanged — they matter, agents run concurrently)
|
||||
|
||||
- Lanes share ONE working tree. Never `git add .` / `git add -A` / `git commit -a`. Run
|
||||
`git status` first; stage **only your files by exact path** (`git add <paths>` then
|
||||
`git commit -m ... -- <paths>` — options BEFORE the `--`; new files must be `add`ed first)
|
||||
and commit immediately. Touch nothing another lane has modified.
|
||||
- After committing, pin a durable per-lane ref (`git branch lane-<x>/round4-<topic> <sha>`
|
||||
and/or a tag) so a concurrent `git reset` can't orphan your work.
|
||||
- Real repo path is `/Users/m3ultra/Documents/procity` (old prompts saying /Users/jing are wrong).
|
||||
- Leave `bash tools/qa.sh --strict` GREEN before you finish. Update your `<X>-progress.md`;
|
||||
leave cross-lane notes in `docs/LANES/LANE_<X>_NOTES.md`, not silent edits to others' files.
|
||||
- `procityprogress.txt` and `settings.local.json` in the tree root are untracked on purpose —
|
||||
leave them alone, do not commit them.
|
||||
- Dev server: `python3 -m http.server 8130 -d web` (may already be up on :8130 — reuse it).
|
||||
|
||||
## Sequencing (who unblocks whom)
|
||||
|
||||
```
|
||||
E1 (ped decimate) ──→ D2 (validate rigs) ──→ F3 (gate-3 re-measure) ─┐
|
||||
B2 (DBG.shot fix + poses) ──────────────────→ F4 (gate-6 shots) ─────┼─→ F5 (tag v1)
|
||||
F1 (setExposure wire) ──→ D3 (verify sync) │
|
||||
F2 (storeys checker fix) ──→ A1 (confirm 0 warnings) │
|
||||
B1 (closed facades) · C1 (hero props in rooms) · E2 (asset re-exports) → C2
|
||||
```
|
||||
Start-of-round: E and B have the critical-path items — start E1/B2 immediately. F1/F2 are
|
||||
small and early. Everything else is parallel.
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — standby + two confirmations
|
||||
|
||||
Small round again. Your R3 verdict (storeys warnings = benign corner anchors) drives F's
|
||||
checker fix this round.
|
||||
|
||||
1. **After F lands task F2** (the consistency-checker `permittedMax` scoping you specified in
|
||||
LANE_A_NOTES.md): re-run `bash tools/qa.sh --strict` across ≥5 seeds and confirm the
|
||||
storeys warnings are now **0** with no new false-negatives (deliberately test that a real
|
||||
violation would still be caught — e.g. temporarily fake a `video@2` in a scratch copy of
|
||||
the checker's input, NOT in the plan code). Confirm `GOLDEN.hash` still `0x098eec2b`.
|
||||
2. **openLate contract audit**: F's night gate asserts `plan.shops.some(s => s.hours[1] >= 22)`.
|
||||
Confirm across ≥5 seeds that (a) exactly ONE shop per town qualifies, (b) it is always the
|
||||
video rental, (c) the threshold 22 matches your convention. If any seed violates it, fix in
|
||||
plan.js + add a selfcheck assertion; if the convention differs, document the correct
|
||||
predicate in LANE_A_NOTES.md for F.
|
||||
3. Otherwise standby: answer cross-lane questions (LANE_A_NOTES.md is your channel).
|
||||
|
||||
**Acceptance**: qa.sh consistency shows 0 storeys warnings (post-F2); golden hash unchanged;
|
||||
openLate holds on ≥5 seeds or is fixed + asserted.
|
||||
|
||||
## Lane B (Streetscape) — closed facades, shot harness, furniture verify
|
||||
|
||||
1. **Closed-facade visual (§3.5 render side — the headline item).** Predicate is ready:
|
||||
`window.PROCITY.isOpen(shop)` + `window.PROCITY.currentHour()`. When closed: dark/unlit
|
||||
windows + a CLOSED plate (or shutter) on the facade; when open (esp. at night for the one
|
||||
`openLate` video shop): lit windows. Also a "CLOSED" door tooltip on aim. Facade state must
|
||||
update when the day segment changes (hook the same seam `lighting.js` uses — don't poll per
|
||||
frame). Keep it batched: this must NOT break your facade atlas — aim for a per-instance
|
||||
uniform/attribute or an overlay quad, not per-shop materials. Re-measure worst view ≤300
|
||||
draws with citizens after.
|
||||
2. **Shot-harness fixes F requested (gate-6 blocker — do early).** In `dbg.js`:
|
||||
(a) fix the headless-composer letterbox in `DBG.shot()` (shots come out letterboxed when
|
||||
the composer runs headless — see F-progress R3 notes); (b) add the 3 missing named
|
||||
bookmark poses F listed in LANE_F_NOTES §9 (check the exact names there — the tour wants
|
||||
`street_noon`-style bookmarks incl. a night-neon and market pose). Verify by running
|
||||
`python3 tools/shots.py` and eyeballing output.
|
||||
3. **Furniture GLB footprint/collision verify (your R3 task 3, now unblocked).** F's §3.4
|
||||
manifest-GLB furniture upgrade is live. Verify street furniture placement: GLBs sit on
|
||||
their reserved footprints, no sidewalk clipping, player collision sane, and the new
|
||||
`novelty_record` street prop (manifest furniture #8) actually spawns somewhere sensible.
|
||||
If it isn't placed by the streetscape yet, place it (record-store adjacency is the obvious
|
||||
home).
|
||||
4. Optional polish: fresh beauty stills for docs/shots/ once 1–3 land.
|
||||
|
||||
**Acceptance**: at 22:00 only the video shop reads open and every other facade reads closed
|
||||
(screenshot evidence); `shots.py` produces un-letterboxed shots incl. the 3 new poses;
|
||||
worst-view draws ≤300 with citizens; qa.sh green.
|
||||
|
||||
## Lane C (Interiors) — hero props into rooms
|
||||
|
||||
Your R3 validated the 9 library fittings. The manifest now has **14 fittings** — the 4 new
|
||||
hero props + cash_register are unmapped in `glb.js`.
|
||||
|
||||
1. **Map + validate the new hero fittings** in `web/js/interiors/glb.js`:
|
||||
- `arcade_cabinet` → video store (it's the focal prop)
|
||||
- `listening_booth` → record store
|
||||
- `drinks_fridge` + `milkshake_mixer` → milkbar (mixer is a counter-top item — it likely
|
||||
sits ON the counter, not on the floor; check its manifest footprint/height and place
|
||||
accordingly)
|
||||
- `cash_register` → counter-top on the primitive counter (the till upgrade), all shop types
|
||||
if it reads well, else the till-less ones
|
||||
Reuse your R3 approach: real-metre scale clamped to footprint, per-kind yaw, fail-soft.
|
||||
Load via the normal `depot:` path or `?localdepot=1` — all 5 are published and live.
|
||||
2. **Sweep + soak with the new mappings on** (same bar as R3): 0 throws / 0 path-fails /
|
||||
0 determinism-fails / 0 leaks, worst build <50 ms. Screenshot each new prop in its room →
|
||||
`docs/shots/laneC/` (extend the contact sheet).
|
||||
3. **Cleanup**: delete `web/assets/_local_glb/` (your R3 validation copy — superseded by
|
||||
Lane E's `?localdepot=1` + `stage_local_depot.py`). It's untracked; just `rm -rf` it.
|
||||
Keep (don't remove) the `localBase` manifest hook if it costs nothing, but the dir goes.
|
||||
4. **Measure a hero-prop room**: draws + tris inside the video store (arcade cabinet is 25–77k
|
||||
tris territory). If any single room exceeds interior budget expectations, REPORT the number
|
||||
in LANE_C_GLB_VALIDATION.md for E's future bake pass — do not block or fix.
|
||||
5. **Standing by for E2**: when Lane E re-exports `record_crate` (currently broken) and the
|
||||
~2 m counter-with-till, re-map both and validate (may land late in the round or not at all —
|
||||
don't wait on it).
|
||||
|
||||
**Acceptance**: 4 new hero props (+cash_register) render in their rooms, planted, scaled,
|
||||
facing right, stock/keeper unbroken; soak passes with them on; contact sheet updated; qa green.
|
||||
|
||||
## Lane D (Citizens) — in-shell rig verify + decimation validation
|
||||
|
||||
1. **In-shell §3.4 verification (now unblocked — F wired the fleet in R3).** In the real game
|
||||
(`web/index.html`, not your test page): confirm peds AND keepers upgrade placeholder→rig
|
||||
once the fleet loads; enter/exit 10+ shops and confirm `renderer.info.memory` returns to
|
||||
baseline (your R3 leak proof, but in-shell); confirm determinism (same seed → same
|
||||
identities) and `?noassets=1` still runs fully placeholder with zero fetches.
|
||||
2. **Validate E's decimated peds (critical path — coordinates with E1).** When E stages the
|
||||
decimated `web/models/peds/*.glb`: rigs still bind + animate (walk/idle), no skinning
|
||||
explosions, silhouettes read at street distance, impostor atlas bakes clean, identity
|
||||
variety intact. Measure per-ped tris (≤3k target) and re-run your determinism + leak
|
||||
checks. Report the numbers in LANE_D_NOTES.md; F re-measures gate 3 after your OK.
|
||||
3. **Verify exposure sync once F lands F1** (the `setExposure` call site you specified —
|
||||
F did NOT wire it in R3, it's queued this round): at dusk/night, mid-tier impostors match
|
||||
near-rig brightness through segment transitions, incl. across a placeholder→rig upgrade.
|
||||
4. Chunk-streamed roster stays design-only (your R3 note). No implementation this round.
|
||||
|
||||
**Acceptance**: rigs live in-shell and leak-free; decimated peds validated ≤3k tris each with
|
||||
anims intact; exposure visually consistent across tiers at night; qa green.
|
||||
|
||||
## Lane E (Assets) — ped decimation (critical path) + asset fixes
|
||||
|
||||
1. **Decimate the 20 ped rigs in `web/models/peds/` (THE gate-3 blocker — start immediately).**
|
||||
Use `gltf-transform` (npx) `simplify` — meshoptimizer works here: these are manifold skinned
|
||||
character meshes, not TRELLIS shell-soup. Requirements:
|
||||
- target **≤3k tris per ped** (integrator decision #1); keep textures (WebP/resize if easy
|
||||
wins are there — file size matters less than tris)
|
||||
- PRESERVE skinning (joints/weights) and do not touch `walk.glb` / `idle.glb` (the shared
|
||||
animation clips) beyond confirming they still retarget
|
||||
- back up originals to a gitignored dir (e.g. `pipeline/.peds_orig/` — add to .gitignore)
|
||||
- stage in place (`web/models/peds/`), eyeball a contact sheet (no melted faces/hands),
|
||||
then hand to Lane D for rig validation (task D2). Record before/after tris + method in
|
||||
pipeline/AUDIT.md.
|
||||
2. **Two asset re-exports for Lane C** (from LANE_C_GLB_VALIDATION.md):
|
||||
- `procity_fit_record_crate_01.glb` — broken (undefined `uri` reference, won't parse).
|
||||
Re-run normalize from source or regenerate; validate it loads in three.js before handing.
|
||||
- counter: a **~2.0–2.3 m long counter WITH a till** (the R3 one is 4 m, till-less, and
|
||||
squashes). Local pipeline (flux_local → trellis_mac), ≤2 attempts each; if both fail,
|
||||
add to the fal fallback list and move on.
|
||||
Publish any new/re-exported GLBs to the depot (the R3 passwordless tailnet path) + re-run
|
||||
`validate_manifest.py --depot` → 0 errors. Note for C in LANE_E notes when live.
|
||||
3. **Do NOT**: spend the fal.ai budget (decision #3), or start the hero-prop bake-to-lowpoly
|
||||
pass (decision #2) — parked unless C reports a room blowing budget AND Fable re-scopes.
|
||||
|
||||
**Acceptance**: all 20 peds ≤3k tris with skinning/anims intact and D's sign-off; record_crate
|
||||
loads; counter attempt resolved (shipped or documented-failed); depot validate 0 errors; qa green.
|
||||
|
||||
## Lane F (Integration) — wire-ups, gate closure, tag v1
|
||||
|
||||
1. **Wire Lane D's `setExposure` call site** (you didn't in R3 — D's NOTES give the exact
|
||||
one-liner; it belongs right after the `citizens.setTimeOfDay(...)` call at
|
||||
`web/index.html:244`): pass the shell's animated `renderer.toneMappingExposure` into
|
||||
`citizens.setExposure(...)` on segment change. Verify with D (D3).
|
||||
2. **Apply Lane A's storeys-checker fix** in the consistency checker (tools/): scope the
|
||||
check to `permittedMax = (max >= 2) ? min(max + 1, 3) : max` per LANE_A_NOTES.md, so the
|
||||
intentional 3-storey corner anchors stop warning while real drift still trips. Expect
|
||||
consistency warnings → 0; A confirms (A1).
|
||||
3. **Gate 3 re-measure** after D2 signs off on E1's decimated peds: busiest intersection,
|
||||
clean single-pass, with rig fleet + citizens at busy midday. Need ≤300 draws AND ≤200k tris.
|
||||
Record in the runbook gate table.
|
||||
4. **Gate 6 closure** after B2: re-run `tools/shots.py` → 10 un-letterboxed beauty shots incl.
|
||||
the 3 new poses → `docs/shots/v1_tour/` + contact sheet. Also capture B1's closed-facade
|
||||
night shot for the tour (the 🔒 toast + dark facades sell §3.5).
|
||||
5. **Tag v1.** When the §4 gate table in LANE_F_NOTES is all-green (1,2,4,5 already ✅; 3 and 6
|
||||
land this round): update the gate table + F-progress, run a final full
|
||||
`bash tools/qa.sh --strict`, then `git tag v1.0` with a summary message. Do NOT push —
|
||||
John pushes. If a gate won't close, document exactly what's short in F-progress and stop
|
||||
at the documentation (no half-tag).
|
||||
6. Ongoing: you own `web/index.html` and tools/ seams — other lanes leave those edits to you
|
||||
(they document call sites in their NOTES instead, as D did).
|
||||
|
||||
**Acceptance**: exposure wired + D-verified; consistency 0 warnings; gates 3 + 6 measured
|
||||
green (or precisely documented why not); v1.0 tagged locally on an all-green qa run.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). Questions → leave them in your NOTES file addressed to Fable; I read
|
||||
everything at round end. Commit atomically, pin your refs, leave it green.*
|
||||
@ -1,250 +0,0 @@
|
||||
# PROCITY — Round 5 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND6_INSTRUCTIONS.md](ROUND6_INSTRUCTIONS.md) (2026-07-14).** Round 5 is
|
||||
> complete — v1.1 tagged (`3a831fe`); B/C/D/E/F delivered, Lane A's task carried to round 6.
|
||||
> Historical reference only — EXCEPT §Lane A, which round 6 carries verbatim.
|
||||
|
||||
Date: 2026-07-14 · Written after **v1.0 tagged** (`2698b40`, all 6 QA gates green, 6-auditor
|
||||
GO). You are an Opus 4.8 agent assigned ONE lane (read your session prompt). Read this file,
|
||||
your `docs/LANES/LANE_<X>_*.md` spec, your `<X>-progress.md`, `docs/V2_IDEAS.md`, and any
|
||||
NOTES addressed to you before touching code.
|
||||
|
||||
Repo state: everything committed on `main` at `2698b40` = `v1.0`. `tools/qa.sh --strict`
|
||||
GREEN 4/4 (selfcheck 1301/1301, fingerprint `0x3fa36874`, consistency 0 warnings, manifest
|
||||
`--depot` 0 errors, 23 GLBs live). Tag is local; pushing is John's.
|
||||
|
||||
## Theme: v1.1 close-out + v2 foundations
|
||||
|
||||
v1 shipped: seed → walkable 90s-AU town, every door opens, keepers, crowds, hours, day/night.
|
||||
Round 5 does two things at once:
|
||||
|
||||
1. **v1.1 (small, this week's polish):** land the ONE v1 loose end — Lane C re-maps the two
|
||||
assets Lane E shipped late in R4 (`record_crate` fixed, `counter_till` new) — plus the
|
||||
hero-prop lowpoly bake. F tags `v1.1`.
|
||||
2. **v2 foundations (the real round):** each lane starts its highest-leverage V2_IDEAS item,
|
||||
**behind a flag, default-off**. The parking-lot doc was written so v2 is wiring, not
|
||||
rework — this round proves that.
|
||||
|
||||
## The v2 prime law (read twice)
|
||||
|
||||
**Default boot must stay v1-identical.** Every v2 feature lands behind a query flag or config
|
||||
default-off. `GOLDEN.hash` (`0x3fa36874`) must not move unless Lane A intentionally versions
|
||||
it (it should not this round). `qa.sh --strict` green at every commit. If your feature is on
|
||||
and something breaks, that's your bug; if your feature is OFF and anything changes, that's a
|
||||
land-blocker.
|
||||
|
||||
## Integrator decisions (so nobody blocks)
|
||||
|
||||
1. **v2 starts with the content firehose** (V2_IDEAS #1): Lane C ports `dig.js` (the
|
||||
crate-riffle interaction) running on the EXISTING procedural stock via the `stockAdapter`
|
||||
seam. Real GODVERSE/BaseGod stock + economy is round 6+ — do NOT wire external databases
|
||||
or thriftgod's `mint()` this round.
|
||||
2. **Hero-prop bake-to-lowpoly is UN-PARKED** (was decision R4#2). Lane C's measurements
|
||||
(record interior ~273k tris; `drinks_fridge` 77k, `counter_till` 32k) justify it. Lane E
|
||||
bakes: target **≤8k tris per hero prop** with baked base-colour (bake THEN simplify —
|
||||
R3 taught us raw TRELLIS shell-soup won't simplify; a voxel-remesh + texture re-bake will).
|
||||
3. **OSM plan import uses cached/fixture data ONLY.** Lane A builds the second plan source
|
||||
against thriftgod's `city_source.json` cache (or a checked-in fixture extract). NO live
|
||||
Overpass/network calls from the repo — the asset-free/zero-network law extends to citygen.
|
||||
4. **fal.ai spend is AUTHORIZED (John, 2026-07-14: account topped up to $22).** Lane E runs
|
||||
the fallback gens — glass-case + bus-shelter, plus one optional magazine-rack attempt —
|
||||
at Hunyuan v2 ~14–17¢/gen, ≤2 attempts each (expected total well under $1). Nothing else
|
||||
on fal without a per-item reason logged in AUDIT.md. Pushing stays John's. Audio stays
|
||||
greenfield-parked (needs an asset-sourcing decision from John).
|
||||
5. **v1.1 tag**: Lane F tags after C1 (re-map) and E1 (bake) land and the tour still looks
|
||||
right. v2 flags do NOT need to be done for v1.1.
|
||||
|
||||
## Process rules (unchanged — agents run concurrently)
|
||||
|
||||
- ONE shared working tree. `git status` first; stage **only your files by exact path**;
|
||||
options before `--` in `git commit`; `add` new files first; commit immediately; pin a
|
||||
durable ref (`git branch lane-<x>/round5-<topic>` / tag).
|
||||
- Leave `qa.sh --strict` GREEN. Update `<X>-progress.md`; cross-lane asks go in your
|
||||
NOTES file, not in others' code. F owns `web/index.html` + `tools/` seams — document your
|
||||
call sites for F instead of editing (the D→F setExposure pattern from R3/R4).
|
||||
- `procityprogress.txt` / `settings.local.json` stay untracked; don't commit them.
|
||||
- Real repo path `/Users/m3ultra/Documents/procity`. Source repos for ports:
|
||||
`~/Documents/90sDJsim` (dig.js), `~/Documents/thriftgod` (city_source/plan import),
|
||||
`~/Documents/dealgod/GODVERSE.md` (read-only reference this round).
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
C1 (re-map counter_till + record_crate) ─┐
|
||||
E1 (hero-prop bake ≤8k) ──→ C verifies ─┼─→ F1 (tag v1.1)
|
||||
│
|
||||
A2 (plan-source seam) · B2 (window shader) · C2 (dig.js) · D2 (streamed roster) · E2 (depot grow)
|
||||
└────────── all behind flags ─────────→ F2 (v2 smoke + runbook)
|
||||
```
|
||||
C1 is quick — do it first so F can tag v1.1 early. All v2 items are parallel and independent.
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — the second plan source (OSM import seam)
|
||||
|
||||
V2_IDEAS: *"Real-OSM plan import (thriftgod `build_city()` route)"* — `generatePlan(seed)`
|
||||
becomes one of two plan sources behind the same CityPlan contract, so B–F never change.
|
||||
|
||||
1. **Extract the contract.** Define/verify a single `CityPlan` producer interface (whatever
|
||||
`generatePlan` returns IS the contract — document its exact shape in CITY_SPEC if not
|
||||
already precise: nodes/edges/blocks/lots/shops/registry fields, hours, openLate, seeds).
|
||||
Add a `planSource` selector: default `'synthetic'` (today's path, byte-identical output —
|
||||
golden hash law), new `'osm'` behind `?plansrc=osm`.
|
||||
2. **Build the OSM source.** Port the relevant parts of thriftgod `server.py:433 build_city()`:
|
||||
read a **checked-in fixture** extracted from thriftgod's `city_source.json` cache (pick one
|
||||
real AU town's worth of shops, a few hundred max — commit the fixture under
|
||||
`web/assets/fixtures/` or `tools/fixtures/`), then dedupe → suburb inference → parody
|
||||
names → seeded hours → emit a valid CityPlan. Deterministic: same fixture + seed → same
|
||||
plan (hash it).
|
||||
3. **Prove it downstream.** Boot the shell with `?plansrc=osm`: streets stream, doors open,
|
||||
interiors build, selfcheck-style invariants hold (write an osm-mode selfcheck variant or
|
||||
parameterize `selfcheck.js` by source — do NOT touch the synthetic golden hash). Screenshot
|
||||
the imported town for docs.
|
||||
4. Document the seam in LANE_A_NOTES for F's determinism-gate extension (F2).
|
||||
|
||||
**Acceptance**: default boot byte-identical (golden `0x3fa36874` untouched); `?plansrc=osm`
|
||||
boots a real-data town end-to-end with invariants green; fixture committed, zero network;
|
||||
qa green.
|
||||
|
||||
## Lane B (Streetscape) — the Vuntra window trick (interior-mapping shader)
|
||||
|
||||
V2_IDEAS: *"parallax interior-mapping shader so a passing pedestrian sees a plausible room
|
||||
through the glass without building it."* You own the facade glass + atlas — this is yours.
|
||||
|
||||
1. **Interior-mapping shader** on shop window glass, behind `?winmap=1`: classic
|
||||
single-cube parallax interior mapping (seeded per-shop room: wall/floor colours from the
|
||||
shop's skin palette, a couple of shelf silhouettes). Must respect §3.5: **closed shops at
|
||||
night show dark rooms** (reuse `window.PROCITY.isOpen`); open shops glow warm.
|
||||
2. **Keep it batched.** One shader/material on the existing glass geometry (or a clone at the
|
||||
same draw count) — per-instance variation via attributes/uniforms, NOT per-shop materials.
|
||||
Re-measure worst continuous-walk view with citizens: **≤300 draws** flag-on.
|
||||
3. **Determinism + fallback**: seeded from `shop.seed`; flag-off path byte-identical to v1
|
||||
(same materials, same draws). `?noassets=1` + winmap must not fetch anything (procedural
|
||||
only).
|
||||
4. Screenshot day + night (open vs closed) → `docs/shots/laneB/` for the v2 gallery.
|
||||
|
||||
**Acceptance**: flag-off identical to v1; flag-on ≤300 draws worst view, rooms visible
|
||||
through glass with parallax, closed shops dark at night; zero fetches; qa green.
|
||||
|
||||
## Lane C (Interiors) — v1.1 re-map, then the dig
|
||||
|
||||
1. **v1.1 re-map (DO FIRST — F tags v1.1 on it).** Lane E's R4 late shipment
|
||||
(LANE_E_NOTES → Lane C):
|
||||
- `record_crate` is FIXED (sourceless-texture slots stripped; verified loading in the
|
||||
vendored GLTFLoader). Your `crate`/`recordBin` mapping already points at it — remove the
|
||||
"fails to load" fail-soft comment, verify the bins actually upgrade now.
|
||||
- `counter_till` is NEW (`procity_fit_counter_till_01.glb`, 1.61×0.64 m, 1.15 m tall,
|
||||
base-origin): **re-add `counter` → `'counter_till'`** to `KIND_TO_GLB` (NOT the old 4 m
|
||||
`counter`/balcao). Decide what happens to your separate `cash_register` counter-top
|
||||
attach on counters that now have a modelled till — avoid double tills (keep
|
||||
cash_register for shop types where it reads better, or drop the attach when the GLB
|
||||
counter is live; your call, document it).
|
||||
- Keeper stand pose must still work on the GLB counter (the counter contract).
|
||||
- Re-run sweep + soak (same bar: 0 throws/leaks, <50 ms), update the contact sheet,
|
||||
commit → tell F (v1.1).
|
||||
2. **Port `dig.js` — the crate riffle (v2 headline, behind `?dig=1`).** Source:
|
||||
`~/Documents/90sDJsim/web/world/dig.js` (self-contained, `onBuy/getCash/spawnClerk/
|
||||
spawnFittings` callback API, ported across 3 games before). Scope for this round:
|
||||
- Walk to a record bin in a record store → riffle interaction (flip through sleeves) using
|
||||
the EXISTING procedural stock via your `stockAdapter(shop, slotKind)` seam — the sleeves
|
||||
you already generate become the riffled items. Seeded order per bin.
|
||||
- Buy/economy: STUB it (a toast + remove-from-bin is fine; `getCash` returns a constant).
|
||||
Real stock/economy is round 6+ (integrator decision #1).
|
||||
- Keep it interior-mode-only and leak-free: entering/leaving the dig disposes cleanly
|
||||
(extend your soak to enter a dig per interior when the flag is on).
|
||||
- F owns the input/mode seam in the shell — document the exact hook you need
|
||||
(e.g. `interiorMode.onInteract(fitting)`) in LANE_C notes; F wires it (F2). Build against
|
||||
your test page in the meantime.
|
||||
3. Re-measure the record interior after E1's baked props land (was ~273k tris) — record the
|
||||
new number in LANE_C_GLB_VALIDATION.md.
|
||||
|
||||
**Acceptance**: C1 committed early, bins + counter GLB-live, no double till, soak green →
|
||||
F tags v1.1; `?dig=1` riffles a real bin on procedural stock, leak-free, flag-off identical;
|
||||
qa green.
|
||||
|
||||
## Lane D (Citizens) — implement the chunk-streamed roster
|
||||
|
||||
Your R3 design note (LANE_D_NOTES) was design-only by instruction. v2 un-parks it: implement,
|
||||
behind `?roster=stream`.
|
||||
|
||||
1. **Implement per your own design**: chunk-local identity keying (same seed + chunk → same
|
||||
residents), windowed spawn/despawn driven by Lane B's `ctx.onChunkBuilt/onChunkDisposed`
|
||||
hooks (they exist — B shipped them in R2), global near-cap across live chunks, ownership
|
||||
hand-off, and the v1 path untouched when the flag is off.
|
||||
2. **Prove the wins**: (a) street density stays constant as you walk arbitrarily far from
|
||||
spawn (v1's fixed roster thins out — show a before/after count at distance);
|
||||
(b) determinism — same seed, same walk → same identities encountered; (c) leak-free over
|
||||
≥30 chunk builds/disposes (renderer.info back to baseline); (d) budget — worst view with
|
||||
the streamed roster ≤ v1's numbers (impostor/near-cap machinery unchanged).
|
||||
3. **Hours-aware density (small, if time allows)**: streamed spawns read the day fraction the
|
||||
way v1 does; optionally bias spawn weights toward open shops at night (the queue/patronage
|
||||
sim stays v2-later — don't build schedules this round).
|
||||
4. Document the flag + any shell call-site change for F in LANE_D_NOTES (F wires index.html).
|
||||
|
||||
**Acceptance**: flag-off identical to v1 (determinism + golden identities); flag-on constant
|
||||
far-field density, deterministic, leak-free, within budget; design note updated to as-built;
|
||||
qa green.
|
||||
|
||||
## Lane E (Assets) — the bake pass + depot growth
|
||||
|
||||
1. **Hero-prop bake-to-lowpoly (un-parked, DO FIRST — C re-measures on it).** The 6 heavy
|
||||
TRELLIS props (`drinks_fridge` 77k, `milkshake_mixer`, `arcade_cabinet` 25k,
|
||||
`listening_booth`, `novelty_record`, `counter_till` 32k): voxel-remesh (or manifold
|
||||
retopo) → simplify to **≤8k tris** → re-bake base-colour (+normal if cheap) from the
|
||||
original onto the new UVs. R3's lesson stands: simplify-only won't work on shell-soup;
|
||||
the re-bake is the point. Pipeline it (`pipeline/bake_lowpoly.py` or a Blender headless
|
||||
script) so it's repeatable; keep originals in a gitignored dir; record before/after in
|
||||
AUDIT.md. Eyeball every prop (no melted labels/faces) → contact sheet. Re-publish to the
|
||||
depot; `validate --depot` 0 errors. Tell C to re-measure.
|
||||
2. **Depot growth for v2 (second priority).** From `~/Documents/3D=models/` pick a shortlist
|
||||
(~8–12) that serves this round's v2 features — dig-scene props (crate variants, price-gun,
|
||||
listening-station headphones), street-life props for D's roster (shopping bags, prams),
|
||||
B's window-room silhouettes if any read better as real minis. Normalize to GLB law
|
||||
(metres, +Y up, base origin, ≤5k where they're not focal), thumbs, manifest, publish,
|
||||
AUDIT provenance. Do NOT add manifest entries no lane consumes — check the other lanes'
|
||||
round-5 notes or ask in yours.
|
||||
3. **fal.ai fallback run (NOW AUTHORIZED — decision #4).** Generate `glass-case` and
|
||||
`bus-shelter` via fal Hunyuan v2 (the two TRELLIS glass rejects), plus ONE optional
|
||||
magazine-rack attempt if the first two go well. ≤2 attempts per prop; expected total
|
||||
<$1 of the $22 balance. Then the normal funnel: normalize (bake-to-lowpoly if heavy per
|
||||
task 1) → eyeball thumbnails (no melted glass/legible branding) → publish → manifest.
|
||||
Log every paid gen + cost in AUDIT.md provenance. If a prop fails both attempts, it keeps
|
||||
its primitive — do not burn extra attempts.
|
||||
4. **Not doing** (still parked): MESHGOD_BATCH re-runs beyond the above, audio assets.
|
||||
|
||||
**Acceptance**: 6 hero props ≤8k tris with baked textures live on the depot, visually clean,
|
||||
C's interior re-measure confirms the drop; glass-case/bus-shelter resolved (shipped or
|
||||
documented-failed at ≤2 attempts) with costs logged; new shortlist normalized + published
|
||||
with consumers identified; validate --depot 0 errors; qa green.
|
||||
|
||||
## Lane F (Integration) — v1.1 tag, v2 seams, gates that grow
|
||||
|
||||
1. **Tag v1.1** once C1 + E1 land: verify the record/milkbar/opshop interiors live (GLB
|
||||
counter + till story correct, bins upgraded, baked props look right), re-capture ONLY the
|
||||
two interior tour shots if visibly improved, qa green → `git tag v1.1` (local; John
|
||||
pushes).
|
||||
2. **Wire the v2 flag seams** as lanes document them (you own `web/index.html` + `tools/`):
|
||||
A's `?plansrc=`, B's `?winmap=`, C's `?dig=1` interact hook (this one is real work — the
|
||||
interior-mode input seam; C specifies, you own the mode state machine), D's
|
||||
`?roster=stream`. Keep every flag default-off and independent.
|
||||
3. **Extend the QA harness for v2** (V2_IDEAS "determinism across engines"):
|
||||
- determinism gate: pin plan-hash + first-interior-hash per (seed, plansrc) — synthetic
|
||||
stays `0x3fa36874`; add the osm fixture hash when A lands.
|
||||
- a **flags-off regression check**: boot with no flags, assert plan hash + a settled
|
||||
draws/tris snapshot within tolerance of v1 numbers (catches "my flag leaks when off").
|
||||
- a v2 smoke: boot each flag on, walk 2 chunks, enter 1 interior (+1 dig if C landed),
|
||||
0 console errors. Wire into qa.sh as a non-strict (warning) gate this round; strict
|
||||
next round.
|
||||
4. **Runbook**: add a §v2 flags table (flag → owner → state → smoke result) to LANE_F_NOTES;
|
||||
archive the R3/R4 instruction files' "current" claims (one line at top: superseded by R5).
|
||||
5. Ongoing: shared-tree marshal — if two lanes collide on a file, you arbitrate.
|
||||
|
||||
**Acceptance**: v1.1 tagged on a green tree; all landed v2 flags wired, independent,
|
||||
default-off proven by the flags-off regression check; v2 smoke running in qa.sh (warn-level);
|
||||
runbook current.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). The four hinges (stockAdapter, plan-source contract, shop.seed law,
|
||||
B's mode machine) are now load-bearing — if your task fights one of them, stop and leave me a
|
||||
note instead of bending it. Commit atomically, pin your refs, leave it green.*
|
||||
@ -1,224 +0,0 @@
|
||||
# PROCITY — Round 6 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND7_INSTRUCTIONS.md](ROUND7_INSTRUCTIONS.md) (2026-07-14).** Round 6 is
|
||||
> complete — ALL six lanes delivered; v2.0-alpha tagged (`6948372`). Historical reference only.
|
||||
|
||||
Date: 2026-07-14 · Written after the Fable round-5 review (`d71420f`). You are an Opus 4.8
|
||||
agent assigned ONE lane (read your session prompt). Read this file, your lane spec, your
|
||||
`<X>-progress.md`, and NOTES addressed to you before touching code.
|
||||
|
||||
Repo state: everything committed on `main` at `d71420f`. **v1.1 tagged** (`3a831fe`).
|
||||
`tools/qa.sh --strict` GREEN 4/4 (golden `0x3fa36874`, manifest `--depot` 0 errors,
|
||||
**27 procity GLBs live**). Three v2 flags live and default-off: `?winmap=1` (B), `?dig=1`
|
||||
(C+F), `?roster=stream` (D). fal balance ~$21.36 after E's $0.64 round-5 run.
|
||||
|
||||
## Where we are / what round 6 is
|
||||
|
||||
Round 5 delivered v1.1 + three of four v2 foundations. Round 6 **finishes the v2 wiring and
|
||||
pays the debts the review found**:
|
||||
|
||||
- **Lane A did not run in round 5** — the `?plansrc=osm` plan source carries over whole.
|
||||
It is the round's biggest single item.
|
||||
- **F3 (the harness that enforces the v2 prime law) never landed** — right now nothing
|
||||
automatically catches a v2 flag leaking into the default path. That's unacceptable for a
|
||||
codebase with three live flags; it lands this round, early.
|
||||
- **Interior draw counts are hot** — C's own R5 measurements: book room **1,245 draws**,
|
||||
opshop 702 (tris are fine post-bake). Almost certainly unbatched per-item stock. Fix
|
||||
before any more interior content lands.
|
||||
- **Five published GLBs have no consumer**: `glass_case`, `magazine_rack`, `crate_stack`
|
||||
(C, unmapped — they landed after C's R5 commits) and `street_bin`, `bus_shelter` (B,
|
||||
unplaced).
|
||||
- **publish.py is destructive** — it glob-rewrites `pipeline/_published.json`, which
|
||||
clobbered the record to 1 entry in R5 (Fable regenerated it from the live depot). E fixes
|
||||
the script this round.
|
||||
|
||||
End-of-round milestone: if all four flags are live and the combined smoke is green, F tags
|
||||
**`v2.0-alpha`** (local — John pushes).
|
||||
|
||||
## The v2 prime law (still in force)
|
||||
|
||||
Default boot stays v1-identical. Golden hash `0x3fa36874` frozen. Flags default-off.
|
||||
`qa.sh --strict` green at every commit. New this round (decision #4): **flags must compose**.
|
||||
|
||||
## Integrator decisions (so nobody blocks)
|
||||
|
||||
1. **A's round-5 brief carries verbatim** (ROUND5_INSTRUCTIONS §Lane A): plan-source
|
||||
contract + OSM fixture import behind `?plansrc=osm`, fixture checked in, zero network.
|
||||
Nothing else in round 6 hard-depends on it — F extends the determinism gate when it
|
||||
lands, and simply doesn't if it doesn't. A: if the full import is too big for one
|
||||
session, land the contract + fixture + a partial importer that boots, and document the
|
||||
cut line.
|
||||
2. **Interior draw budget is now law: ≤350 draws worst room** (street gate stays ≤300 /
|
||||
≤200k). C enforces via its sweep; F adds it to the harness once C's fix lands. Rationale:
|
||||
interiors render alone (no street behind), so 350 is generous — 1,245 is a bug, not a
|
||||
budget problem.
|
||||
3. **publish.py becomes non-destructive**: merge into `_published.json` (union of existing +
|
||||
newly pushed), never replace. `_published.json` is the provenance record of everything
|
||||
ever published — treat it like AUDIT.md.
|
||||
4. **Flag-composition law**: any subset of v2 flags on together must work. F smokes the
|
||||
all-on combo (`?winmap=1&dig=1&roster=stream` + plansrc if landed). If two flags fight,
|
||||
the later-landing lane owns the fix; F arbitrates.
|
||||
5. **fal spend**: no new gens authorized this round. The $21.36 balance sits. (Re-gen of a
|
||||
*failed* existing prop at ≤2 attempts is fine if something turns out broken in use —
|
||||
log it in AUDIT.md.)
|
||||
6. **Tag `v2.0-alpha`** (F, end of round): all four flags landed + flags-off regression
|
||||
green + all-on combo smoke green + qa.sh strict green. If A doesn't land, tag anyway
|
||||
with three flags and note it — don't hold the milestone hostage.
|
||||
|
||||
## Process rules (unchanged — agents run concurrently)
|
||||
|
||||
- ONE shared working tree. `git status` first; stage only your files by exact path; options
|
||||
before `--`; `add` new files first; commit immediately; pin a durable per-lane ref.
|
||||
- Leave `qa.sh --strict` GREEN. Update `<X>-progress.md`; cross-lane asks in your NOTES.
|
||||
- F owns `web/index.html` + `tools/`; document call sites for F instead of editing.
|
||||
- `procityprogress.txt` / `settings.local.json` stay untracked.
|
||||
- Repo: `/Users/m3ultra/Documents/procity`. Ports: `~/Documents/thriftgod` (A's import),
|
||||
`~/Documents/90sDJsim` (reference). Dev server `:8130`.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
F1 (flags-off regression + v2 smoke) ── land FIRST, everything else runs under it
|
||||
E1 (publish.py fix) ── early, protects the record while others publish
|
||||
C1 (interior draw batching) ──→ C2 (map 3 new fittings) ──→ F re-smoke interiors
|
||||
A1 (plansrc, carried) ─────────→ F2 (determinism gate + plansrc smoke, if landed)
|
||||
B1 (street_bin + bus_shelter) · D1 (roster hardening) ── parallel
|
||||
all green ──→ F3 (tag v2.0-alpha)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — the second plan source (CARRIED FROM ROUND 5, unchanged)
|
||||
|
||||
Your round-5 brief carries verbatim — execute ROUND5_INSTRUCTIONS.md §Lane A. Summary:
|
||||
|
||||
1. **Extract the contract**: document the exact CityPlan shape `generatePlan` returns in
|
||||
CITY_SPEC (nodes/edges/blocks/lots/shops/registry/hours/openLate/seeds); add a
|
||||
`planSource` selector — default `'synthetic'` byte-identical (golden hash law), `'osm'`
|
||||
behind `?plansrc=osm`.
|
||||
2. **Build the OSM source**: port the relevant parts of thriftgod `server.py:433
|
||||
build_city()` reading a **checked-in fixture** (one real AU town's shops, few hundred
|
||||
max, extracted from thriftgod's `city_source.json` cache) → dedupe → suburb inference →
|
||||
parody names → seeded hours → valid CityPlan. Deterministic (fixture+seed → same plan,
|
||||
hash it). **Zero network.**
|
||||
3. **Prove it downstream**: `?plansrc=osm` boots the shell end-to-end — streets, doors,
|
||||
interiors; invariants green (parameterize selfcheck by source; synthetic golden hash
|
||||
untouched). Screenshot the imported town.
|
||||
4. Document the seam in LANE_A_NOTES for F's determinism-gate extension.
|
||||
|
||||
**Integrator note (decision #1):** if one session can't finish, land contract + fixture +
|
||||
a booting partial importer and document the cut — a half-landed seam beats another no-show.
|
||||
|
||||
**Acceptance**: default boot byte-identical; `?plansrc=osm` boots a real-data town with
|
||||
invariants green; fixture committed; zero network; qa green.
|
||||
|
||||
## Lane B (Streetscape) — place the street props
|
||||
|
||||
1. **Place `street_bin_01` and `bus_shelter_01`** (published R5, no consumer yet). Bins:
|
||||
seeded placement along footpaths (corner/mid-block, sparse — this is a country town, not
|
||||
a mall). Bus shelter: main-street edges, oriented to the kerb, sensible spacing (1–3 per
|
||||
town) — note it's also the future stop for V2's tram/bus loop, so place it ON the street
|
||||
graph (an edge attribute or deterministic edge pick), not free-floating, and document the
|
||||
placement rule in LANE_B_NOTES for whoever builds the vehicle loop.
|
||||
2. **Footprint/collision verify** both (same bar as your R4 furniture verify): planted,
|
||||
no sidewalk clipping, player collision sane, correct facing, deterministic per seed.
|
||||
3. **Re-measure** worst continuous-walk view with citizens: still ≤300 draws (the props are
|
||||
instanced/batched like the rest of your furniture — don't add per-instance materials).
|
||||
4. Optional polish: a `winmap` bookmark shot for the gallery (day + night through glass).
|
||||
|
||||
**Acceptance**: both props placed deterministically + collision-verified; worst view ≤300
|
||||
draws; placement rule documented; qa green.
|
||||
|
||||
## Lane C (Interiors) — kill the draw count, then map the three orphans
|
||||
|
||||
1. **Interior draw batching (do FIRST — it's now law, decision #2: ≤350 draws worst room).**
|
||||
Your R5 numbers: book 1,245 / opshop 702 / milkbar 573 draws. Diagnose first (one
|
||||
`renderer.info` breakdown per room type — is it per-item stock meshes? per-sleeve
|
||||
canvases? fitting clones?), then batch: merge static stock into per-fitting or per-room
|
||||
geometry (`BufferGeometryUtils.mergeGeometries` on shared-material groups), or instance
|
||||
repeated items. Constraints: determinism (same seed → same room), dig compatibility
|
||||
(`?dig=1` riffle needs its bin's sleeves — keep dug bins' items addressable or rebuild
|
||||
the bin's mesh on dig-open), leak-free dispose, keeper/stock contracts unchanged.
|
||||
Re-measure ALL room types after; extend your sweep to **assert ≤350** so it can't regress.
|
||||
2. **Map the three orphaned fittings** in `glb.js` (published + in manifest since E3/E2,
|
||||
after your R5 commits): `glass_case` → toy/pawn/milkbar (display case — front-of-room or
|
||||
counter-adjacent), `magazine_rack` → milkbar/pawn (it's the R3 thin-wire reject reborn
|
||||
via fal — eyeball it in-room), `crate_stack` → record/opshop back-room dressing. Real-
|
||||
metre scale + per-kind yaw as ever; sweep + soak with them on; extend the contact sheet.
|
||||
3. Re-run the dig soak (`?dig=1`) after the batching change — the riffle must still work on
|
||||
the batched bins.
|
||||
|
||||
**Acceptance**: worst room ≤350 draws with the sweep asserting it; all three fittings live
|
||||
in rooms, validated, screenshotted; dig still works + leak-free; qa green.
|
||||
|
||||
## Lane D (Citizens) — harden the streamed roster toward default-on
|
||||
|
||||
The roster flips to default in v2 proper; this round produces the evidence for that call.
|
||||
|
||||
1. **Full soak with `?roster=stream` on**: the standard gate-2 walk (≥30 chunks, ≥15 shops)
|
||||
plus your far-field walk — leak-free (renderer.info at baseline after chunk disposes),
|
||||
0 console errors, heap stable, determinism (same seed + walk → same identities).
|
||||
2. **Hours-aware density** (if not already in): streamed spawns respect the day fraction
|
||||
like v1; at night, density drops and the openLate video shop's block stays lively.
|
||||
3. **Flag-composition check with C**: `?roster=stream&dig=1` — entering a dig from a street
|
||||
with a streamed roster, exiting, walking on. No interaction with interior mode (roster
|
||||
is street-tier), but prove it.
|
||||
4. **The numbers memo**: write LANE_D_NOTES "roster default-on readiness" — perf deltas
|
||||
(draws/tris/frame-time worst view vs v1 roster), memory over a 30-chunk walk, density
|
||||
uniformity evidence, known gaps. F + Fable use it to decide the flip in round 7.
|
||||
|
||||
**Acceptance**: streamed-roster soak green; composition with dig proven; readiness memo
|
||||
written with real numbers; flag still default-off; qa green.
|
||||
|
||||
## Lane E (Assets) — make the pipeline non-destructive, then on-call
|
||||
|
||||
1. **Fix publish.py (decision #3)**: merge newly-published files into `_published.json`
|
||||
(read existing → union → sorted write), never replace. While you're in there: after any
|
||||
publish, verify the file against the live depot list (`/api/list` filtered to
|
||||
`procity_*`) and warn on drift. Add a small check to `validate_manifest.py` (or a new
|
||||
gate): every manifest `depot:` file must appear in `_published.json` — so a future
|
||||
clobber fails QA instead of hiding.
|
||||
2. **Re-verify the record**: run your fixed publish path's verify against the depot and
|
||||
confirm the 27-entry `_published.json` Fable regenerated is exactly right (fix if not).
|
||||
3. **On-call**: B may need bus-shelter/bin scale or origin tweaks once placed; C may find
|
||||
`magazine_rack`/`glass_case` issues in-room (fal re-gen of a *broken* prop is authorized
|
||||
at ≤2 attempts per decision #5, logged). Answer within your NOTES.
|
||||
4. **No speculative generation** — the depot grows consumer-driven only. No fal spends
|
||||
beyond decision #5's broken-prop clause.
|
||||
|
||||
**Acceptance**: publish.py merges (proven by a dry-run + a real no-op run), QA catches
|
||||
manifest-vs-published drift; `_published.json` verified against depot; asset asks answered;
|
||||
qa green.
|
||||
|
||||
## Lane F (Integration) — the enforcement harness, then the alpha tag
|
||||
|
||||
1. **F1 — flags-off regression + v2 smoke (LAND FIRST; carried from R5 where it slipped).**
|
||||
Per your own LANE_F_NOTES §"v2 QA harness extensions":
|
||||
- **Flags-off regression**: boot with no flags → assert plan hash == golden + a settled
|
||||
draws/tris snapshot within tolerance of the v1.1 baseline. This is the enforcement arm
|
||||
of the prime law — with three live flags it is overdue.
|
||||
- **v2 smoke**: for each flag (`winmap`, `dig`, `roster`, + `plansrc` when A lands):
|
||||
boot flag-on, walk 2 chunks, enter 1 interior (dig: open one riffle), 0 console errors.
|
||||
- **All-on combo** (decision #4): one smoke with every landed flag on simultaneously.
|
||||
- Wire into qa.sh as a **warn-level gate this round** (strict in round 7). Keep it
|
||||
headless-runnable and fast (<2 min) or it won't get run.
|
||||
2. **F2 — plansrc integration (only if A lands)**: wire the shell plan bootstrap selector,
|
||||
extend the determinism gate to pin (seed, plansrc) hashes, add plansrc to the smoke.
|
||||
3. **F3 — tag `v2.0-alpha`** (decision #6): all landed flags smoke-green + flags-off
|
||||
regression green + qa strict green → tag locally with a summary (three flags is
|
||||
acceptable if A no-shows again — note it in the tag message). Update the flags table +
|
||||
runbook + F-progress. John pushes.
|
||||
4. Add C's interior ≤350-draw assertion to the harness once C1 lands (a per-room-type
|
||||
check in the smoke or soak — coordinate via NOTES).
|
||||
5. Marshal duty continues.
|
||||
|
||||
**Acceptance**: flags-off regression + per-flag + all-on smokes running in qa.sh (warn);
|
||||
determinism gate extended if plansrc lands; `v2.0-alpha` tagged on a green tree; runbook +
|
||||
flags table current.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). Round 5's lesson: the review caught a clobbered provenance file, a
|
||||
skipped harness, and a silent no-show — all three were invisible to lanes' own green
|
||||
self-reports. F1 exists so the machine catches the next one. Commit atomically, pin your
|
||||
refs, leave it green.*
|
||||
@ -1,233 +0,0 @@
|
||||
# PROCITY — Round 7 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND8_INSTRUCTIONS.md](ROUND8_INSTRUCTIONS.md) (2026-07-14).** Round 7 is
|
||||
> complete — roster flipped to default, `?stock=real` live, harness strict, v2.0-beta tagged
|
||||
> (`4feba02`). B (weather) and A (osm parity) slipped — round 8 carries both §Lane A and §Lane B
|
||||
> verbatim; everything else is historical reference only.
|
||||
|
||||
Date: 2026-07-14 · Written after the Fable round-6 review (`6948372`) and the **v2.0-alpha
|
||||
tag**. You are an Opus 4.8 agent assigned ONE lane (read your session prompt). Read this
|
||||
file, your lane spec, your `<X>-progress.md`, and NOTES addressed to you first.
|
||||
|
||||
Repo state: everything committed on `main` at `6948372` = `v2.0-alpha`. `tools/qa.sh
|
||||
--strict` GREEN **5/5** (incl. the v2 flags harness at warn level). Four v2 flags live,
|
||||
default-off: `?plansrc=osm` · `?winmap=1` · `?dig=1` · `?roster=stream`. Goldens: synthetic
|
||||
`0x3fa36874`, osm `0x34cfdec0`. Interior law ≤350 draws/room (worst 313, sweep asserts).
|
||||
27 procity GLBs live. fal balance ~$21.36. Tags `v1.0`/`v1.1`/`v2.0-alpha` await John's push.
|
||||
|
||||
## Theme: the flip round + the content firehose
|
||||
|
||||
Round 6 built the machinery; round 7 turns two keys:
|
||||
|
||||
1. **`?roster=stream` becomes the DEFAULT.** D's readiness memo says it's deterministic,
|
||||
leak-free, hours-aware, and composes — gated only on one optimisation (the ped sub-mesh
|
||||
merge, so full density fits the draw budget). E merges, D validates, F flips.
|
||||
2. **Real stock enters the dig** (the reason this system exists — V2_IDEAS #1, deferred
|
||||
twice): a curated GODVERSE content pack feeds C's `stockAdapter` behind `?stock=real`,
|
||||
so the crates you riffle hold *real* record sleeves instead of parody canvas.
|
||||
|
||||
Plus: the flags harness graduates **warn → strict**, B ships weather, A hardens osm mode.
|
||||
End-of-round milestone: **`v2.0-beta`** (F tags; criteria in decision #6).
|
||||
|
||||
## Prime-law amendment (read carefully — the baseline moves this round)
|
||||
|
||||
The v2 prime law ("default boot stays v1-identical") gets its first **deliberate,
|
||||
versioned** exception: when F flips the roster default, the default boot changes. Protocol
|
||||
(decision #1): the flip commit and the flags-off-baseline re-pin happen in ONE F-owned
|
||||
commit, with the old baseline recorded in the commit message. **Plan goldens do NOT move**
|
||||
(`0x3fa36874` / `0x34cfdec0` — the plan layer is untouched by the roster). Any other lane
|
||||
whose feature would move the default boot: that's still a land-blocker — the exception is
|
||||
the flip only.
|
||||
|
||||
## Integrator decisions (so nobody blocks)
|
||||
|
||||
1. **Roster-flip protocol**: E1 (sub-mesh merge) → D1 (validate + full-density numbers) →
|
||||
F1 (flip default; keep `?roster=v1` as the escape hatch back to the fixed roster;
|
||||
re-pin the flags-off draws/tris baseline in the same commit). Order is strict — no lane
|
||||
pre-empts the flip.
|
||||
2. **Real-stock scope**: read-only content, NO economy backend. E builds the pack offline
|
||||
from GODVERSE (`~/Documents/dealgod/GODVERSE.md` — recordgod's 185k vinyl w/ images is
|
||||
the target): curate ~200–400 sleeves per record-shop archetype (+ optional small packs
|
||||
for book/toy if cheap), bake into atlas textures + a JSON index (id, title, artist,
|
||||
price band, atlas UV), publish atlases to the depot like any asset. C consumes behind
|
||||
`?stock=real` through the existing `stockAdapter` seam. Buying stays a stub. `?noassets`
|
||||
and flag-off behavior untouched. **No runtime network beyond the existing depot path;
|
||||
no databases; nothing from GODVERSE checked into the repo except the built pack index
|
||||
if it's small.** Parody-name law: run titles/artists through the same
|
||||
no-real-trademarks screen as everything else — if the source data is real-world
|
||||
metadata, parody-transform it in the pack builder (deterministic).
|
||||
3. **Weather is B's round**, behind `?weather=1`: seeded per-day weather state (clear /
|
||||
overcast / rain), rain particles + wet-ground response, wind sway on awnings/trees if
|
||||
cheap. The sky domes for it already exist in `web/assets/gen/`. Tram loop is a stretch
|
||||
goal ONLY if weather lands early (`?tram=1`, instanced, one path over B's bus_shelter
|
||||
stops, no traffic sim).
|
||||
4. **Harness graduates to strict** (F, after the flip lands): a red flags-harness now
|
||||
fails qa.sh. New flags this round (`stock`, `weather`, `tram` if it lands) enter at
|
||||
warn and graduate next round.
|
||||
5. **fal**: no new spends. Broken-prop re-gen clause (≤2 attempts, logged) still stands.
|
||||
6. **Tag `v2.0-beta`** (F, end of round) when: roster default-on + escape hatch verified ·
|
||||
flags harness strict + green · `?stock=real` riffles real sleeves in a record shop ·
|
||||
`?weather=1` rains · qa strict green. If stock OR weather slips, tag anyway with what
|
||||
landed and note it — same no-hostage rule as always.
|
||||
|
||||
## Process rules (unchanged)
|
||||
|
||||
- ONE shared working tree. `git status` first; stage only your files by exact path;
|
||||
options before `--`; `add` new files first; commit immediately; pin a durable lane ref.
|
||||
- Leave `qa.sh --strict` GREEN. Update `<X>-progress.md`; cross-lane asks in your NOTES.
|
||||
- F owns `web/index.html` + `tools/`; document call sites for F.
|
||||
- `procityprogress.txt` / `settings.local.json` stay untracked.
|
||||
- Repo `/Users/m3ultra/Documents/procity`; GODVERSE reference `~/Documents/dealgod`;
|
||||
dev server `:8130`.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
E1 (ped sub-mesh merge) ──→ D1 (validate + density numbers) ──→ F1 (FLIP + re-pin baseline)
|
||||
E2 (GODVERSE stock pack) ──→ C1 (?stock=real in the dig) ──────→ F2 (strict harness + new smokes)
|
||||
B1 (?weather=1) · A1 (osm parity + 2nd town) ── parallel ──────→ F3 (tag v2.0-beta)
|
||||
```
|
||||
E has both feeder tasks — start E1 immediately (it gates the flip), E2 right after.
|
||||
F1 lands before F2 (strict) so the baseline only re-pins once.
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — osm mode to parity + a second town
|
||||
|
||||
The Melbourne import works; make osm a first-class citizen, not a demo.
|
||||
|
||||
1. **Selfcheck parity**: parameterize the full selfcheck suite by plan source so osm plans
|
||||
get the same invariant coverage as synthetic (lots/shops integrity, hours contract,
|
||||
exactly-one openLate, storeys ranges, determinism, <100ms). Wire so `qa.sh` runs both
|
||||
(F already pins both goldens — extend the checks behind them).
|
||||
2. **A second fixture town**: extract another AU town from the thriftgod cache (different
|
||||
scale/shape than inner-Melbourne — a smaller coastal or regional strip is ideal) →
|
||||
`?plansrc=osm&town=<key>`. Same laws: checked-in module fixture, zero network,
|
||||
deterministic, pinned golden per town (give F the hashes).
|
||||
3. **Document the "add a town" recipe** in LANE_A_NOTES (fixture extraction → module →
|
||||
golden pin) so future towns are mechanical.
|
||||
4. Watch for: osm towns must satisfy the SAME downstream contracts (openLate exactly-one,
|
||||
registry types, hours sanity) — if a real town's data fights a contract, the importer
|
||||
normalizes it (and logs what it changed), the contract doesn't bend.
|
||||
|
||||
**Acceptance**: full selfcheck green on every (source, town); second town boots end-to-end;
|
||||
recipe documented; both plan goldens unchanged, new town's golden pinned; qa green.
|
||||
|
||||
## Lane B (Streetscape) — weather
|
||||
|
||||
`?weather=1` (decision #3). Seeded, deterministic, cheap.
|
||||
|
||||
1. **Weather state**: seeded per (citySeed, day) — clear / overcast / rain (weights feel
|
||||
AU-plausible). Expose `window.PROCITY.weather` for other lanes (D may thin crowds in
|
||||
rain later; not this round).
|
||||
2. **Rain**: a particle layer (instanced/points, ONE draw), audible-later, camera-following;
|
||||
wet ground = roughness/darkening response on street surfaces; puddle sheen if cheap.
|
||||
Sky: pick the matching dome from the existing `sky-*.jpg` set (summer storm / grey
|
||||
drizzle are already in `web/assets/gen/`).
|
||||
3. **Wind**: subtle sway on awnings + trees during overcast/rain if it fits the budget —
|
||||
vertex-shader time offset, not per-frame CPU.
|
||||
4. **Budget + composition**: worst view ≤300 draws with weather on; works with winmap
|
||||
(rain outside a lit window is the money shot — screenshot it), night, and interiors
|
||||
(rain audio/visual stops inside; interiors are separate scenes so mostly free).
|
||||
5. **Stretch ONLY if 1–4 land early**: `?tram=1` — one instanced tram/bus on a seeded
|
||||
main-street loop over your bus_shelter stops, door-stop pause at each. No traffic sim.
|
||||
|
||||
**Acceptance**: `?weather=1` deterministic per seed/day; rain + wet ground + matching sky
|
||||
render; ≤300 draws; composes with winmap/night; flag-off identical; screenshots (incl.
|
||||
rain-through-window); qa green.
|
||||
|
||||
## Lane C (Interiors) — real sleeves in the crates
|
||||
|
||||
`?stock=real` (decision #2). The stockAdapter seam you built in v1 finally eats.
|
||||
|
||||
1. **Define the pack contract with E** (you own the consumer side): what the JSON index +
|
||||
atlases must provide per item (title/artist/price-band/atlas+UV). Put the agreed schema
|
||||
in LANE_C_NOTES *first* so E builds against it — a half-day handshake beats a rework.
|
||||
2. **Consume**: when `?stock=real` and the pack is available (depot or `?localdepot=1`),
|
||||
record-shop bins draw sleeves from the pack (seeded pick per bin — same seed, same
|
||||
crate contents) instead of parody canvas; the dig riffle shows real covers + pack
|
||||
titles/prices; the pull-out card shows title/artist/price.
|
||||
3. **Laws**: deterministic per shop.seed · leak-free (atlas textures dispose with the room
|
||||
or share via the loader cache — prove with the soak) · ≤350 draws (atlas = the sleeves
|
||||
stay batched; assert via drawSweep) · `?noassets` and flag-off untouched · fail-soft
|
||||
(pack missing → parody canvas, warn once).
|
||||
4. **Extend the dig soak**: `?dig=1&stock=real` enter/riffle/exit × N rooms, zero leaks,
|
||||
determinism.
|
||||
5. If E's pack covers book/toy too, wire those shelves as a bonus — record shops are the
|
||||
acceptance bar.
|
||||
|
||||
**Acceptance**: riffling a bin with `?stock=real` shows real (parody-screened) sleeves with
|
||||
titles/prices, deterministic + leak-free + ≤350 draws; fail-soft proven; contract doc'd;
|
||||
qa green.
|
||||
|
||||
## Lane D (Citizens) — validate the merge, hand F the flip
|
||||
|
||||
1. **D1 — validate E1's merged peds** (the flip gate): rigs bind + animate (walk/idle),
|
||||
silhouettes/materials intact, impostor bake clean, identity variety preserved. Then the
|
||||
numbers: draws-per-near-rig before/after, worst-view draws/tris at FULL density
|
||||
(`?roster=stream`, no reduced-density concession), 30-chunk soak leak-free. Write the
|
||||
go/no-go into LANE_D_NOTES with the exact figures — F flips on your word (decision #1).
|
||||
2. **Full-density defaults**: with the merge in, set the stream path's density/near-cap
|
||||
defaults to the values your memo said full density needs — these become the shipping
|
||||
defaults when F flips.
|
||||
3. **Post-flip verification** (after F1 lands): default boot runs the streamed roster —
|
||||
re-run your determinism + far-field-density + leak checks on a NO-FLAG boot; verify the
|
||||
`?roster=v1` escape hatch actually restores the fixed roster.
|
||||
4. Do NOT start rain-behavior/schedules/queues — crowds react to weather in a later round.
|
||||
|
||||
**Acceptance**: merged peds validated with published numbers; go/no-go memo written; after
|
||||
the flip, no-flag boot passes your full check suite; escape hatch verified; qa green.
|
||||
|
||||
## Lane E (Assets) — the two feeders
|
||||
|
||||
Both critical-path. E1 gates the flip; E2 gates the stock. In that order.
|
||||
|
||||
1. **E1 — ped sub-mesh merge (START IMMEDIATELY).** Per-ped GLBs carry multiple
|
||||
primitives/materials → multiple draws per near rig. Merge to (ideally) ONE primitive +
|
||||
one atlased material per ped: gltf-transform `join`/`palette`/`instance` pipeline —
|
||||
**preserving skinning (joints/weights), morphs if any, and compatibility with the shared
|
||||
walk/idle clips**. Backup originals (gitignored), process all 19, verify each loads +
|
||||
binds in the vendored GLTFLoader, record before/after draws+tris in AUDIT.md, hand to D.
|
||||
2. **E2 — the GODVERSE stock pack.** Build `pipeline/build_stock_pack.py`: read recordgod
|
||||
data from `~/Documents/dealgod` (fixture-extract like A's pattern — the pipeline reads
|
||||
the local dataset at build time; NOTHING queries it at runtime), curate 200–400 sleeves
|
||||
(image quality + variety), **parody-transform titles/artists deterministically** (the
|
||||
no-real-trademarks law applies to metadata too), bake atlas PNG/WebPs + JSON index to
|
||||
C's schema (wait for C's contract note — it's their task 1), publish atlases to the
|
||||
depot, stage for `?localdepot=1`. Provenance in AUDIT.md (source dataset, count,
|
||||
transform). Small packs for book spines / toy boxes only if the record pack lands early.
|
||||
3. **On-call**: B may want a rain-streak sprite or puddle texture (procedural first —
|
||||
generate only if truly needed, flux_local, free).
|
||||
|
||||
**Acceptance**: E1 — all peds single-draw (or documented floor), skinned + animating, D
|
||||
signs off; E2 — pack published + staged, index validates against C's schema, parody screen
|
||||
run, provenance logged; qa green (incl. your own provenance-drift gate).
|
||||
|
||||
## Lane F (Integration) — flip, tighten, tag
|
||||
|
||||
1. **F1 — the roster flip (after D1's go).** In `web/index.html`: streamed roster becomes
|
||||
default; `?roster=v1` restores the fixed roster; `?roster=stream` stays accepted (now a
|
||||
no-op). **Same commit**: re-pin the flags-off regression baseline (draws/tris snapshot)
|
||||
to the new default boot, old values in the commit message (prime-law amendment). Plan
|
||||
goldens untouched. Then boot no-flags + escape hatch and eyeball both.
|
||||
2. **F2 — harness to strict (after F1).** Flags harness failures now fail qa.sh. Add
|
||||
smokes for the new flags as they land: `stock=real` (enter record shop, open dig,
|
||||
assert non-parody sleeve count > 0), `weather=1` (boot, assert particle layer + wet
|
||||
response active, 0 errors), `tram=1` if B ships it. New flags enter at warn (decision
|
||||
#4), existing four + flags-off are strict.
|
||||
3. **F3 — tag `v2.0-beta`** (decision #6 criteria): summary tag message with the flip
|
||||
baseline note; update flags table + runbook + F-progress. Local; John pushes (remind
|
||||
him v1.0/v1.1/v2.0-alpha are also still unpushed).
|
||||
4. **Marshal**: this round has two hard sequams (E1→D1→F1, E2→C1) — if a lane stalls,
|
||||
flag it in F-progress early rather than at round end. The R5 lesson (silent no-show)
|
||||
now has a harness for code, but not for schedule — you're the schedule harness.
|
||||
|
||||
**Acceptance**: default boot = streamed roster with re-pinned baseline + working escape
|
||||
hatch; harness strict + green with new-flag smokes; `v2.0-beta` tagged on a green tree;
|
||||
runbook/flags table current.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). Two keys turn this round: the town gets permanently busier, and the
|
||||
crates get real. Both have hard sequencing — respect the arrows, commit atomically, pin
|
||||
your refs, leave it green.*
|
||||
@ -1,193 +0,0 @@
|
||||
# PROCITY — Round 8 lane instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-14 · Written after the Fable round-7 review (`16d4f2b`) and the **v2.0-beta
|
||||
tag** (`4feba02`). You are an Opus 4.8 agent assigned ONE lane (read your session prompt).
|
||||
Read this file, your lane spec, your `<X>-progress.md`, and NOTES addressed to you first.
|
||||
|
||||
Repo state: everything committed on `main` at `16d4f2b`. `tools/qa.sh --strict` GREEN 5/5
|
||||
with the flags harness **STRICT**. **The streamed roster is the DEFAULT** (`?roster=v1` is
|
||||
the escape hatch; baseline re-pinned 164 draws / 66k tris; plan goldens frozen at
|
||||
`0x3fa36874` synthetic / `0x34cfdec0` osm-Melbourne). `?stock=real` puts real GODVERSE
|
||||
sleeves in record-shop crates and digs. John is pushing main + v1.0/v1.1/v2.0-alpha/
|
||||
v2.0-beta to origin. fal balance ~$21.36.
|
||||
|
||||
## Theme: catch-up + the town comes alive
|
||||
|
||||
Round 7 turned the two big keys but **two lanes slipped**: B (weather) and A (osm parity)
|
||||
have no round-7 work. Round 8 is those debts plus the "living town" tier that the flip and
|
||||
the stock pack unlock:
|
||||
|
||||
- **A and B execute their carried tasks** — B's weather is now also a dependency for D.
|
||||
- **C widens real stock** (book/toy) and gives the dig a working **buy loop v0**.
|
||||
- **D makes crowds react** — weather + shop patronage (peds actually enter shops).
|
||||
- **E feeds packs** and stays the asset kitchen.
|
||||
- **F graduates the new smokes to strict** and tags **`v2.0-rc`**.
|
||||
|
||||
A slip note (this applies to everyone): a lane that can't finish its round MUST commit a
|
||||
partial + write the cut line in its progress doc before the session ends. Round 7's slips
|
||||
were silent again — the schedule harness (F) catches them at round end, but a one-line
|
||||
"stopped at X because Y" turns a re-derive into a resume.
|
||||
|
||||
## Prime law (post-amendment state)
|
||||
|
||||
Default boot = streamed roster at the re-pinned baseline; plan goldens frozen; everything
|
||||
NEW still lands behind a default-off flag; flags compose; `qa.sh --strict` green at every
|
||||
commit. The flags harness is strict — if your feature leaks into the default path, qa fails
|
||||
and it's yours to fix.
|
||||
|
||||
## Integrator decisions (so nobody blocks)
|
||||
|
||||
1. **B goes first on weather** (carried R7 brief, unchanged — ROUND7 §Lane B): D's crowd
|
||||
reactions consume `window.PROCITY.weather`, so B1 landing early unblocks D2. If B slips
|
||||
again, D ships patronage only (hours-based) and weather-reaction carries — do not fake a
|
||||
weather source.
|
||||
2. **Buy loop v0 scope (C)**: in-memory only. Player cash starts at a seeded-per-town float
|
||||
(e.g. $40–60), HUD chip, `onBuy` deducts price + removes the sleeve from the bin +
|
||||
adds to a simple inventory list (I key). NO persistence (no localStorage — a page reload
|
||||
resets; that's fine for v0), no selling, no economy sim. Deterministic world, free-agent
|
||||
player: purchases mutate live room state only, never generation.
|
||||
3. **Pack expansion (E)**: book spines + toy boxes packs, same pipeline/laws as the record
|
||||
pack (build-time extract, parody-transform, atlas + index, depot publish, provenance).
|
||||
Target ~150–250 items each; reuse C's schema (already proven); C wires shelves.
|
||||
4. **Patronage v0 scope (D)**: streamed peds occasionally path to an OPEN shop's door,
|
||||
pause, "enter" (despawn with a door-swing tell or fade), re-emerge after seeded dwell.
|
||||
No interior presence (interiors stay player-only this round), no queues. Respect hours
|
||||
(closed shops get no visitors; the openLate video shop gets night traffic).
|
||||
5. **Audio stays parked** (needs John's sourcing decision — flagging it: one line from John
|
||||
unlocks street ambience + door bell, the V2_IDEAS "greenfield" item).
|
||||
6. **Tag `v2.0-rc`** (F, end of round) when: weather + patronage + buy-v0 + ≥1 new pack
|
||||
landed and smoke-green, stock/weather smokes graduated strict, qa strict green. Same
|
||||
no-hostage rule: tag what landed, note what slipped.
|
||||
|
||||
## Process rules (unchanged)
|
||||
|
||||
- ONE shared working tree; stage only your files by exact path; options before `--`;
|
||||
`add` new files first; pin a durable lane ref; leave qa GREEN; progress doc + NOTES.
|
||||
- F owns `web/index.html` + `tools/`; document call sites for F.
|
||||
- `procityprogress.txt` / `settings.local.json` stay untracked; `pipeline/_stockpack/`
|
||||
and staging dirs are gitignored — keep build output out of the repo.
|
||||
- Repo `/Users/m3ultra/Documents/procity`; GODVERSE at `~/Documents/dealgod`; dev `:8130`.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
B1 (weather, carried) ──────────→ D2 (weather-reactive crowds) ─┐
|
||||
E1 (book/toy packs) ──→ C2 (wire shelves) ──┐ │
|
||||
C1 (buy loop v0) ── independent ─────────────┼──→ F1 (strict graduations + new smokes)
|
||||
A1 (osm parity + 2nd town, carried) ─────────┘ └──→ F2 (tag v2.0-rc)
|
||||
D1 (patronage, hours-based) ── independent of B
|
||||
```
|
||||
B and E start immediately (both feed others). C1 and D1 need nobody.
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — carried round-7 brief, unchanged
|
||||
|
||||
Execute ROUND7_INSTRUCTIONS.md §Lane A verbatim. Summary: (1) selfcheck parity for osm
|
||||
plans (full invariant suite runs on both sources; F pins both goldens); (2) a second
|
||||
fixture town (`?plansrc=osm&town=<key>`, checked-in module, zero network, golden pinned —
|
||||
pick a small coastal/regional strip for contrast with inner-Melbourne); (3) the "add a
|
||||
town" recipe in LANE_A_NOTES; (4) importer normalizes real-world data to the contracts
|
||||
(exactly-one openLate etc.) and logs what it changed.
|
||||
|
||||
This brief has now slipped twice (R7; and R4–R5 for its predecessor). If a full session
|
||||
isn't available, land ANY complete subset with a cut-line note — parity alone is a fine
|
||||
round.
|
||||
|
||||
**Acceptance**: full selfcheck green on every (source, town); second town boots end-to-end
|
||||
(or a documented cut); goldens pinned; qa green.
|
||||
|
||||
## Lane B (Streetscape) — carried round-7 brief: weather
|
||||
|
||||
Execute ROUND7_INSTRUCTIONS.md §Lane B verbatim. Summary: `?weather=1` — seeded per
|
||||
(citySeed, day) state (clear/overcast/rain) exposed at `window.PROCITY.weather`; rain
|
||||
particles (ONE draw), wet-ground response, matching sky dome from the existing set; wind
|
||||
sway if cheap; ≤300 draws worst view; composes with winmap/night (rain-through-lit-window
|
||||
screenshot); flag-off identical. Tram (`?tram=1`) remains the stretch, only after weather.
|
||||
|
||||
**New this round**: D consumes `window.PROCITY.weather` (decision #1) — expose it early
|
||||
(even while polishing visuals) and document the shape in LANE_B_NOTES: at minimum
|
||||
`{ state: 'clear'|'overcast'|'rain', intensity: 0..1 }`, stable per day segment.
|
||||
|
||||
**Acceptance**: as ROUND7 §Lane B, plus the documented `PROCITY.weather` contract; qa green.
|
||||
|
||||
## Lane C (Interiors) — buy loop v0 + wire the new packs
|
||||
|
||||
1. **C1 — buy loop v0** (decision #2, in the dig, works with parody AND real stock):
|
||||
seeded starting cash → HUD chip (street + interior); pulling the front sleeve and
|
||||
confirming buys it: cash decreases by the sleeve's price, sleeve leaves the bin (bin
|
||||
mesh updates — your batching keeps dug bins addressable, use that), inventory list on
|
||||
I. Broke = can't buy (a "not enough cash" tell). In-memory only; reload resets.
|
||||
Leak/determinism laws: world generation unchanged (purchases are runtime state);
|
||||
dig soak extended to buy 2 items per visit — no leaks, ≤350 draws.
|
||||
2. **C2 — wire book spines + toy boxes** when E1's packs land: `?stock=real` extends to
|
||||
book/toy shelves through the same stockAdapter path (static shelf stock; digging other
|
||||
shop types is NOT this round). Fail-soft per pack (record pack present but book missing
|
||||
→ records real, books parody).
|
||||
3. Keep the pack schema doc current if E's new packs need any field additions.
|
||||
|
||||
**Acceptance**: buying works end-to-end in a dig (cash/HUD/inventory/bin-update), soak
|
||||
green with purchases; book+toy shelves show real stock when packs present, fail-soft
|
||||
otherwise; ≤350 draws; qa green.
|
||||
|
||||
## Lane D (Citizens) — the crowd comes alive
|
||||
|
||||
1. **D1 — shop patronage v0** (decision #4, independent of B): streamed peds occasionally
|
||||
select an OPEN shop within their chunk, path to its door, pause, despawn-with-tell,
|
||||
re-emerge after a seeded dwell. Hours-aware: closed = no visitors; night = the openLate
|
||||
video shop is the only draw. Deterministic per seed+time; budget-neutral (it's the same
|
||||
peds, different paths); leak-free (despawn/respawn through your existing lifecycle).
|
||||
Behind `?patronage=0` OFF-switch — patronage is default-ON for streamed rosters (it's
|
||||
crowd behavior, not a new system; the flags-off baseline may move a hair — coordinate
|
||||
the re-pin with F if draws shift, same protocol as the flip).
|
||||
2. **D2 — weather reaction** (after B1 exposes `PROCITY.weather`): rain → density drops
|
||||
(~40–60%), walk speed up slightly, more peds choose shop doors (shelter patronage);
|
||||
clear → v1 behavior. Read the contract from LANE_B_NOTES; do not import B's modules.
|
||||
3. Re-run the roster soak with patronage on (leak-free over the despawn/respawn cycles).
|
||||
|
||||
**Acceptance**: peds visibly enter/leave open shops day-long and cluster on the video shop
|
||||
at night; deterministic + leak-free + budget-neutral; rain thins and redirects the crowd
|
||||
(or documented as carried if B slips); qa green.
|
||||
|
||||
## Lane E (Assets) — pack kitchen
|
||||
|
||||
1. **E1 — book spines + toy boxes packs** (decision #3): same pipeline as the record pack
|
||||
(`build_stock_pack.py` — generalize it rather than copying), build-time extract from
|
||||
the GODVERSE datasets (bookgod/toygod per GODVERSE.md), parody-transform, ~150–250
|
||||
items each, atlas + index to C's schema, depot publish + local stage, provenance in
|
||||
AUDIT.md. Book spines are TALL-thin (different atlas cell aspect) — coordinate the
|
||||
cell geometry with C before baking.
|
||||
2. **E2 — pack QA**: extend your provenance-drift gate (or add a small check) to validate
|
||||
pack indexes — every entry has atlas+UV in range, price band sane, no empty titles —
|
||||
so a bad bake fails qa instead of rendering blank sleeves.
|
||||
3. **On-call**: B may want a rain-streak/puddle texture (procedural first, flux_local if
|
||||
truly needed — free); D may want a door-swing tell sprite (same rule).
|
||||
4. No fal spends (broken-prop clause stands). Audio assets stay parked (decision #5).
|
||||
|
||||
**Acceptance**: two new packs live (depot + staged), indexes validate in qa, C consumes
|
||||
them; pack-QA gate green; provenance logged; qa green.
|
||||
|
||||
## Lane F (Integration) — graduate, marshal, tag
|
||||
|
||||
1. **F1 — smoke graduations + additions**: `stock=real` and (when B lands) `weather=1`
|
||||
graduate warn → strict. New smokes at warn: buy-v0 (open dig, buy 1 item, assert cash
|
||||
decreased + bin count dropped, 0 errors), patronage (advance time, assert ≥1 ped
|
||||
despawns at an open shop door over N seconds), book/toy stock (enter book shop with
|
||||
`?stock=real`, assert real spine meshes), 2nd osm town if A lands (pin its golden).
|
||||
2. **F2 — patronage default-on baseline**: if D1 moves the flags-off draws/tris beyond
|
||||
tolerance, re-pin with the same one-commit protocol as the flip (old values in the
|
||||
message). Plan goldens still frozen.
|
||||
3. **F3 — tag `v2.0-rc`** (decision #6 criteria; no-hostage rule). Update flags table +
|
||||
runbook; note anything carried. Remind John of unpushed refs if any remain.
|
||||
4. **Marshal**: B and A are on carried briefs — check for their commits mid-round; if
|
||||
either stalls again, note it in F-progress the moment you see it, not at tag time.
|
||||
|
||||
**Acceptance**: graduated smokes strict + green; new smokes running at warn; baseline
|
||||
re-pin (if needed) done by protocol; `v2.0-rc` tagged on a green tree; runbook current.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). The machine is built and strict; this round is about the town
|
||||
feeling inhabited — rain on the awnings, peds ducking into shops, and walking out of a
|
||||
dig with a record you paid for. Commit atomically, pin your refs, leave it green — and if
|
||||
you must stop early, say where and why.*
|
||||
@ -1 +0,0 @@
|
||||
<!doctype html><meta charset="utf-8"><title>PROCITY contact sheet</title><style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}.row{display:flex;gap:8px}figure{margin:0;flex:1;min-width:0}img{width:100%}figcaption{padding:2px 0;color:#8f8}.empty{display:flex;align-items:center;justify-content:center;border:1px dashed #444;color:#666;min-height:120px}</style><h3>street_noon</h3><div class="row"><figure><img src="v1_tour/street_noon.png" loading="lazy"><figcaption>street_noon (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>arcade</h3><div class="row"><figure><img src="v1_tour/arcade.png" loading="lazy"><figcaption>arcade (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>market_square</h3><div class="row"><figure><img src="v1_tour/market_square.png" loading="lazy"><figcaption>market_square (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>milkbar_dusk</h3><div class="row"><figure><img src="v1_tour/milkbar_dusk.png" loading="lazy"><figcaption>milkbar_dusk (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>night_neon</h3><div class="row"><figure><img src="v1_tour/night_neon.png" loading="lazy"><figcaption>night_neon (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>warehouse_fringe</h3><div class="row"><figure><img src="v1_tour/warehouse_fringe.png" loading="lazy"><figcaption>warehouse_fringe (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>residential_collar</h3><div class="row"><figure><img src="v1_tour/residential_collar.png" loading="lazy"><figcaption>residential_collar (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>record_interior</h3><div class="row"><figure><img src="v1_tour/record_interior.png" loading="lazy"><figcaption>record_interior (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>opshop_interior</h3><div class="row"><figure><img src="v1_tour/opshop_interior.png" loading="lazy"><figcaption>opshop_interior (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div><h3>crossroads_busy</h3><div class="row"><figure><img src="v1_tour/crossroads_busy.png" loading="lazy"><figcaption>crossroads_busy (v1_tour)</figcaption></figure><figure class="empty"><figcaption>reference — none</figcaption></figure></div>
|
||||
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 795 KiB |
|
Before Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 787 KiB |
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 815 KiB |
|
Before Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 818 KiB |
|
Before Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 796 KiB |
|
Before Width: | Height: | Size: 815 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 788 KiB |
|
Before Width: | Height: | Size: 625 KiB |
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 534 KiB |
@ -90,31 +90,6 @@ cleared. These are **excluded from the manifest**:
|
||||
> 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.
|
||||
|
||||
## Provenance — round-3 hero props (for the depot publish sign-off)
|
||||
|
||||
Per the round-3 instruction ("record the per-asset provenance list in AUDIT.md first, then hand
|
||||
John the two-liner"). The hero props have the **cleanest provenance in the whole set** — they are
|
||||
100% generated on-device, from no third-party source:
|
||||
|
||||
| asset (`procity_fit/street_<id>_01.glb`) | origin | licence zone |
|
||||
|---|---|---|
|
||||
| **5 generated** — listening_booth, drinks_fridge, arcade_cabinet, milkshake_mixer (fit) · novelty_record (street) | **fully synthetic, local**: concept image = MODELBEAST `flux_local` (FLUX.2-klein-4B, Apache-2.0 model) → mesh = `trellis_mac` (TRELLIS.2, an original reconstruction of that concept image). No scanned/third-party geometry, no copyrighted source asset. | 🟢 original work (CC0-equivalent) |
|
||||
| cash_register (`vintage-cash-register.glb`) | **reuse** of John's own `thriftgod_hero` asset, already live on 3GOD (round-3 reuse check) — not regenerated. | 🟢 same as existing depot set |
|
||||
| magazine_rack | **not shipped** — TRELLIS wire rack floored ~112k tris (thin-wire gotcha) → in-engine primitive. | n/a |
|
||||
| glass_case, bus_shelter | **not shipped** — TRELLIS failed on the transparent glass → fal.ai fallback list (John's go — Hunyuan v2 ~14–17¢/gen: ~$0.28–0.34 for the pair, ~$1 worst case w/ retries) or primitive. | n/a |
|
||||
|
||||
- **Prompts were generic period objects with explicit `no text, no brands, no people`.** Every kept
|
||||
prop was eyeballed for accidental baked branding (contact sheet `docs/shots/laneE/props_gen_local.png`).
|
||||
The only textual marks are the **drinks-fridge's weathered faux-stickers** — verified at full concept
|
||||
resolution to be **garbled, illegible pseudo-lettering** (no real trademark; e.g. a faux barcode +
|
||||
nonsense glyphs), which reads as period flavour and blurs out at mesh-texture resolution. 🟢 cleared.
|
||||
- Tri/size note: TRELLIS meshes are dense multi-shell scans; `normalize.py`'s COLLAPSE decimate floors
|
||||
well above the ≤5k library budget (**25–77k tris, ~0.3–3.5 MB/prop**; neither COLLAPSE nor meshopt
|
||||
can reduce shell-soup without a voxel-remesh that destroys the baked UVs). Accepted for **focal,
|
||||
optional, LOD-able** props (texture-intact) — flagged for a later texture-bake-to-lowpoly / gltfpack pass.
|
||||
- These GLBs stage locally (`web/assets/models/`, git-ignored, served by `?localdepot=1`) and await
|
||||
John's depot publish; they are **not** committed binaries (same policy as the library `_normalized` set).
|
||||
|
||||
## Coverage vs the registry fittings needs
|
||||
|
||||
| shop type | fittings need | covered by | gap → |
|
||||
@ -158,150 +133,3 @@ Raw stats for every **parsable `.glb`** (59 of the ~69 library entries; the rest
|
||||
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`.
|
||||
|
||||
## Round 4 — ped-rig decimation (gate-3 blocker)
|
||||
|
||||
The 19 street ped rigs in `web/models/peds/` were **~31k–56k tris each (924k total)** — the cause of
|
||||
gate 3 failing (~279k tris at the busiest intersection vs ≤200k). Decimated to **≤3k tris each**
|
||||
(integrator decision #1) via `gltf-transform` `weld` → `simplify` (meshoptimizer):
|
||||
|
||||
```
|
||||
gltf-transform weld in.glb _w.glb
|
||||
gltf-transform simplify _w.glb out.glb --ratio 0.05 --error 0.12
|
||||
```
|
||||
|
||||
meshoptimizer works cleanly here (proper manifold skinned character meshes, NOT the TRELLIS
|
||||
shell-soup of the R3 hero props). Result: **924,153 → 46,551 tris** (48.6k → 2.45k avg; range
|
||||
1564–2805, all ≤3k), **36M → 5M** on disk. **Skinning fully preserved** — `JOINTS_0`/`WEIGHTS_0`
|
||||
intact, skeleton unchanged (nodes + 65 joints identical to source), so the shared `walk.glb`/
|
||||
`idle.glb` clips retarget unchanged (both left untouched). Contact sheet:
|
||||
`docs/shots/laneE/peds_decimated.png` — silhouettes/textures read clean, no melted faces/hands
|
||||
(fine for street distance). Originals backed up (gitignored) at `pipeline/.peds_orig/`; per-ped
|
||||
before/after in `pipeline/_peds_decim.json`. Lane D validates rig binding + anims (task D2); Lane F
|
||||
re-measures gate 3 (~24-rig near cap × ≤3k ≈ ≤67k fleet contribution).
|
||||
|
||||
## Round 4 — E2 asset re-exports (published)
|
||||
|
||||
- **`procity_fit_record_crate_01.glb` re-export (fixed).** The R3 normalize left a **sourceless
|
||||
texture** (packed AO/roughness map didn't survive the WebP export; the material's
|
||||
`metallicRoughnessTexture` + `occlusionTexture` pointed at it), which crashed three.js GLTFLoader
|
||||
with `undefined 'uri'`. `pipeline/fix_glb_textures.py` strips material slots that reference a
|
||||
sourceless texture (base-colour + normal kept; the orphan is never loaded). Verified it loads in
|
||||
the vendored `GLTFLoader` (5000 tris, textures ok); re-published to the depot.
|
||||
- **`procity_fit_counter_till_01.glb` (new).** ~2 m timber shop counter with a period beige till —
|
||||
`flux_local` concept (3 rolls for the ~2:1 aspect) → `trellis_mac` mesh → `normalize.py`. 1.61 ×
|
||||
0.64 m, 1.15 m tall, 32k tris (TRELLIS shell-soup floor, within decision-#2's 25–77k for focal
|
||||
props). **Provenance: 100% synthetic/on-device** (FLUX.2-klein-4B + TRELLIS.2 on the M3 Ultra), no
|
||||
third-party source — cleanest licence zone. Replaces the R3 4 m till-less counter for the interior
|
||||
counter slot (Lane C maps `counter` → `counter_till`); the balcao 4 m `counter` stays for long-bar use.
|
||||
|
||||
## Round 5 — E1: hero-prop bake-to-lowpoly (un-parked)
|
||||
|
||||
The 6 heavy TRELLIS hero props were **25k–77k tris** (shell-soup — simplify-only floors there,
|
||||
R3's lesson). Rebuilt topology + re-baked base colour via `pipeline/bake_lowpoly.py` (Blender
|
||||
headless): **voxel-remesh → triangulate → decimate ≤8k → smart-UV → Cycles DIFFUSE(COLOR) bake
|
||||
HP→LP → WebP export**. All **≤8k tris**, base colour baked onto fresh UVs, self-illuminated a
|
||||
touch (matches normalize). Originals backed up (gitignored) at `pipeline/.props_orig/`; batch spec
|
||||
`pipeline/batch_bake.json`; results `pipeline/_bake_results.json`.
|
||||
|
||||
| prop | tris before → after | voxel | size on disk | note |
|
||||
|---|---|---|---|---|
|
||||
| drinks_fridge | 77047 → 8000 | 0.03 | 5.3M → 0.9M | hollow glass-door box — needed a coarser voxel (0.012 grabbed the internal shelves into wireframe) |
|
||||
| milkshake_mixer | 73492 → 7991 | 0.005 | 4.7M → 0.9M | small part, fine voxel |
|
||||
| listening_booth | 53502 → 7986 | 0.012 | 3.4M → 0.9M | panel + stool |
|
||||
| counter_till | 32226 → 7999 | 0.012 | 1.9M → 0.9M | timber counter + till |
|
||||
| novelty_record | 26486 → 8000 | 0.014 | 1.8M → 0.9M | thick disc + base |
|
||||
| arcade_cabinet | 25142 → 8000 | 0.012 | 1.5M → 0.8M | boxy cabinet |
|
||||
|
||||
Fleet: **288k → 48k tris**, **18M → 5.2M**. Every one **eyeballed** (contact sheet
|
||||
`docs/shots/laneE/props_baked.png` — no melted labels/faces; the fridge re-bake was the only redo)
|
||||
and **verified loading in the vendored `GLTFLoader`** (tris ≤8k, baked texture present).
|
||||
Re-published to the depot (`validate --depot` 0 errors). **Provenance unchanged: 100% synthetic/
|
||||
on-device** (FLUX.2-klein-4B + TRELLIS.2 + Blender bake, no third-party source). → Lane C
|
||||
re-measures the record interior (was ~273k tris).
|
||||
|
||||
## Round 5 — E3: fal.ai fallback props (glass-case + bus-shelter + magazine-rack)
|
||||
|
||||
The two R3 TRELLIS glass rejects + the optional magazine-rack, generated on **fal.ai Hunyuan3D v2**
|
||||
(John-authorized, decision #4). Pipeline: `flux_local` concept (frosted/opaque surfaces so the
|
||||
reconstructor has something to grab — clear glass is what defeated TRELLIS) → `pipeline/gen_props_fal.py`
|
||||
(fal `fal-ai/hunyuan3d/v2`, key from MODELBEAST settings, never hardcoded) → `normalize.py` (scale/
|
||||
origin) → `bake_lowpoly.py` (voxel-remesh → ≤8k → **EMIT albedo bake**; Hunyuan materials need the
|
||||
base-colour→emission route, DIFFUSE(COLOR) baked them near-black).
|
||||
|
||||
**Paid gen log (fal Hunyuan3D v2, ~$0.16/gen):**
|
||||
|
||||
| prop | attempts | cost | outcome |
|
||||
|---|---|---|---|
|
||||
| glass-case | 1 (frosted-glass concept) | ~$0.16 | ✅ solid case, timber base, items visible — TRELLIS couldn't; 40k→8k baked |
|
||||
| bus-shelter | 2 (att.1 dropped the thin perspex panels → att.2 with an opaque back wall) | ~$0.32 | ✅ solid shelter, back wall + roof + bench; 40k→8k |
|
||||
| magazine-rack | 1 (optional; solid-timber, not wire) | ~$0.16 | ✅ tiered timber rack w/ magazines — beat the R3 wire failure; 40k→8k |
|
||||
|
||||
**Total fal spend this round: ~$0.64** of the $22 balance (well under the <$1 estimate). Guardrail
|
||||
held — nothing else on fal. All 3 ≤8k tris, base-colour baked, **verified loading in the vendored
|
||||
GLTFLoader**, eyeballed (`docs/shots/laneE/props_fal.png` — no melted glass, no legible branding),
|
||||
published, `validate --depot` 0 err. **Provenance: FLUX.2-klein-4B concept + fal Hunyuan3D v2 mesh +
|
||||
Blender bake — synthetic, no third-party source** (Hunyuan output is model-generated geometry).
|
||||
Raw fal GLBs + concepts kept in gitignored `pipeline/.genprops/`. These retire the last two
|
||||
primitive-only fittings (glass_case, magazine_rack) + add a street bus_shelter.
|
||||
|
||||
## Round 5 — E2: depot growth (consumer-driven)
|
||||
|
||||
The round-5 v2 features are **procedural** (dig.js riffles generated sleeve art; B's window shader is
|
||||
100% procedural; D's roster is spawn logic) — so no forced GLB needs. Per Fable "don't add manifest
|
||||
entries no lane consumes", E2 is deliberately light: two already-synthetic depot props with clear
|
||||
consumers, re-normalized to procity names + thumbs (both already ≤5k tris, metre-scale, base-origin):
|
||||
|
||||
| new id | source | footprint / h | consumer |
|
||||
|---|---|---|---|
|
||||
| `street/bin` (`procity_street_bin_01.glb`) | `council-bin.glb` (thriftgod_hero, on depot) | 0.57×0.68 m, 1.0 m | Lane B street dressing (towns need bins) |
|
||||
| `fit/crate_stack` (`procity_fit_crate_stack_01.glb`) | `milk-crate-stack.glb` (thriftgod_hero) | 0.71×0.72 m, 0.9 m | Lane C record-store floor piles / dig context |
|
||||
|
||||
Both 3k tris, verified loading in the vendored GLTFLoader, published, `validate --depot` 0 err.
|
||||
Provenance: MeshGod-generated (thriftgod_hero) — synthetic, web-ok. **Note:** also resolved Lane B's
|
||||
B3 ask — `novelty_record` (was 26.5k, spiking the busy view) is now 8k via the E1 bake. No speculative
|
||||
props added; further depot growth is available on request once C's dig / D's roster name concrete needs.
|
||||
|
||||
## Round 7 — E1: ped sub-mesh merge to one draw (gates the roster flip)
|
||||
|
||||
The 19 ped rigs carried **1–9 primitives / 1–3 materials each = 92 draws** for the fleet (≈116 at
|
||||
the 24-rig near cap) — the optimisation D's memo gates full-density roster on. `pipeline/merge_ped.py`
|
||||
(headless Blender) merges each ped to **ONE primitive + one atlased material**: join meshes → pack
|
||||
every material's UV islands into a non-overlapping atlas UV → **EMIT-bake each source material's
|
||||
base-colour into one 1024 atlas** (route base-colour→emission via the ORIGINAL uv so the atlas UV
|
||||
doesn't hijack the lookup) → single material. **Skinning + skeleton fully preserved** (65 joints
|
||||
intact on all 19, `JOINTS_0`/`WEIGHTS_0` kept; `walk`/`idle` untouched).
|
||||
|
||||
Result: **fleet 92 → 19 draws (1/ped)**; at the 24-rig near cap **116 → 24 draws** (79% cut). Size
|
||||
5M → 4M. Verified in the vendored `GLTFLoader`: all 19 load with `isSkinnedMesh` + bind the shared
|
||||
`walk.glb` clip; `man_suit` (2-mat) poses to **byte-identical dimensions vs the original**
|
||||
(109.038 × 62.5) proving the skin deforms unchanged; `man_athletic` (3-mat, most complex atlas)
|
||||
eyeballed correct (skin/top/pants regions all right). Pre-merge decimated peds backed up (gitignored)
|
||||
in `pipeline/.peds_r6/`; per-ped in `pipeline/_peds_merge.json`. → **Lane D validates (D1)** —
|
||||
full silhouette/material/impostor/identity pass + full-density numbers — then **F flips the roster**.
|
||||
|
||||
## Round 7 — E2: GODVERSE stock pack pipeline (real sleeves for the dig)
|
||||
|
||||
`pipeline/build_stock_pack.py` builds C's `?stock=real` content pack **offline** (nothing queries the
|
||||
dataset at runtime): read recordgod (`dealgod` Postgres `discogs_release`, 185k vinyl w/ cover URLs) →
|
||||
curate N sleeves (deterministic md5 sample, varied) → **parody-transform title/artist** → fetch covers →
|
||||
bake 8×8 atlas WebPs (256px cells) + a JSON index (`{id,title,artist,price,price_band,atlas,uv}`) →
|
||||
stage for `?localdepot` / publish to depot.
|
||||
|
||||
**Parody law (metadata):** real (title, artist) → deterministic seed → recombined from period-AU word
|
||||
banks; the output **shares no source token** (`Dark Side of the Moon`/`Pink Floyd` → `Quiet Milk Bar`/
|
||||
`The Galahs`) so there is **zero real-trademark leakage**, yet it reads as plausible AU record stock,
|
||||
and the same source row yields the same parody every build. Verified deterministic + token-disjoint.
|
||||
|
||||
**Pipeline PROVEN** end-to-end with `--sample 24` (synthetic solid-colour sleeves): 24 → one 2048 atlas
|
||||
+ index, UVs correct, price bands (bargain/standard/collector/grail) working; sample pack staged to
|
||||
`web/assets/models/` so Lane C can wire the consumer against the **real index format now**.
|
||||
|
||||
**The real 200-400 pack is blocked on two external deps (flagged to Fable):**
|
||||
1. **`GODVERSE_DSN`** — the dealgod Postgres DSN (creds). Postgres is up on `localhost:5432`; the
|
||||
builder reads the DSN from env (I don't hunt for creds). `--from-db --count 300` runs once it's set.
|
||||
2. **Lane C's pack schema** (their task 1, not yet posted) — I built to the proposed fields above;
|
||||
`INDEX_FIELDS` adjusts to C's LANE_C_NOTES contract when it lands.
|
||||
Provenance of the real pack: source = Discogs metadata + cover images (recordgod), **metadata
|
||||
parody-transformed** (no real titles/artists ship); covers are real release art (read-only, non-economy
|
||||
per decision #2). Small book/toy packs are a follow-up once the record pack lands.
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
# PROCITY bespoke hero props — MeshGod batch (Step 5)
|
||||
|
||||
> **Budget-gated. Do NOT run without John's go.** Pricing corrected (2026-07-14, John): fal
|
||||
> Hunyuan v2 is **~14–17¢/gen, not 30¢**. Post-R3/R4 the batch is mostly filled free on
|
||||
> MODELBEAST; the fal remainder is only **glass-case + bus-shelter** (+ optionally
|
||||
> magazine-rack) ≈ **$0.28–0.51 one-pass, ~$1 worst case** with the ≤2-attempts rule. Everything
|
||||
> **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).
|
||||
|
||||
|
||||
@ -71,29 +71,10 @@ no key, weights cached). It runs one GPU job at a time. Add skins by editing `PR
|
||||
`.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 — `gen_props.py` (MODELBEAST) *(round-3: DONE, free, on-device)*
|
||||
The `meshgod_batch.json` gap list now goes through MODELBEAST instead of fal.ai — free, local:
|
||||
```bash
|
||||
python3 pipeline/gen_props.py --dry-run # list props + what's done
|
||||
python3 pipeline/gen_props.py --concepts # flux_local concept images → .genprops/*.png (eyeball!)
|
||||
python3 pipeline/gen_props.py --mesh --only glass-case # trellis_mac 1024_cascade image→GLB (~5-8 min)
|
||||
python3 pipeline/gen_props.py --mesh # all props with a concept, no glb
|
||||
# then normalize (uses local Blender), stage, catalogue:
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python pipeline/normalize.py -- pipeline/batch_props.json pipeline/_normalized web/assets/thumbs
|
||||
cp pipeline/_normalized/_results.json pipeline/_props_results.json && python3 pipeline/build_manifest.py
|
||||
```
|
||||
**5 shipped** (arcade_cabinet, listening_booth, drinks_fridge, milkshake_mixer, novelty_record) + cash-register
|
||||
reused from depot. `magazine-rack` → primitive (thin-wire gotcha); `glass-case`/`bus-shelter` → fal.ai
|
||||
fallback (TRELLIS can't do transparent glass; `sf3d --draft` gives a featureless blob). TRELLIS meshes run
|
||||
heavy (25–77k tris) — see AUDIT provenance. **Eyeball every thumbnail**; ≤2 local attempts then fal (John's go).
|
||||
|
||||
### 7. Local-depot mode — `loaders.js ?localdepot=1` + `stage_local_depot.py` *(round-3: DONE)*
|
||||
Lets Lanes B/C/F validate GLB loading with **no network / before publish**:
|
||||
```bash
|
||||
python3 pipeline/stage_local_depot.py # mirror _normalized/*.glb → web/assets/models/ (git-ignored)
|
||||
# then load the game with ?localdepot=1 — depot:<file> resolves to assets/models/<file> instead of the CDN
|
||||
```
|
||||
### 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
|
||||
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
[
|
||||
{
|
||||
"file": "comical_boy_01.glb",
|
||||
"before": 31292,
|
||||
"after": 1564
|
||||
},
|
||||
{
|
||||
"file": "comical_luchador_01.glb",
|
||||
"before": 49576,
|
||||
"after": 2478
|
||||
},
|
||||
{
|
||||
"file": "man_athletic_01.glb",
|
||||
"before": 56258,
|
||||
"after": 2805
|
||||
},
|
||||
{
|
||||
"file": "man_bouncer_01.glb",
|
||||
"before": 41532,
|
||||
"after": 2073
|
||||
},
|
||||
{
|
||||
"file": "man_casual_01.glb",
|
||||
"before": 42818,
|
||||
"after": 2132
|
||||
},
|
||||
{
|
||||
"file": "man_casual_02.glb",
|
||||
"before": 49180,
|
||||
"after": 2458
|
||||
},
|
||||
{
|
||||
"file": "man_casual_03.glb",
|
||||
"before": 47867,
|
||||
"after": 2386
|
||||
},
|
||||
{
|
||||
"file": "man_dj_streetwear_01.glb",
|
||||
"before": 53876,
|
||||
"after": 2692
|
||||
},
|
||||
{
|
||||
"file": "man_elder_01.glb",
|
||||
"before": 32098,
|
||||
"after": 1603
|
||||
},
|
||||
{
|
||||
"file": "man_grunge_01.glb",
|
||||
"before": 50187,
|
||||
"after": 2500
|
||||
},
|
||||
{
|
||||
"file": "man_soldier_ww2_01.glb",
|
||||
"before": 54668,
|
||||
"after": 2729
|
||||
},
|
||||
{
|
||||
"file": "man_suit_01.glb",
|
||||
"before": 54938,
|
||||
"after": 2741
|
||||
},
|
||||
{
|
||||
"file": "man_worker_hivis_01.glb",
|
||||
"before": 53738,
|
||||
"after": 2680
|
||||
},
|
||||
{
|
||||
"file": "woman_athleisure_01.glb",
|
||||
"before": 52126,
|
||||
"after": 2596
|
||||
},
|
||||
{
|
||||
"file": "woman_business_01.glb",
|
||||
"before": 52838,
|
||||
"after": 2711
|
||||
},
|
||||
{
|
||||
"file": "woman_casual_01.glb",
|
||||
"before": 53460,
|
||||
"after": 2670
|
||||
},
|
||||
{
|
||||
"file": "woman_casual_02.glb",
|
||||
"before": 48229,
|
||||
"after": 2769
|
||||
},
|
||||
{
|
||||
"file": "woman_chef_01.glb",
|
||||
"before": 49794,
|
||||
"after": 2485
|
||||
},
|
||||
{
|
||||
"file": "woman_smart_01.glb",
|
||||
"before": 49678,
|
||||
"after": 2479
|
||||
}
|
||||
]
|
||||
@ -1,103 +0,0 @@
|
||||
{
|
||||
"peds": [
|
||||
{
|
||||
"file": "comical_boy_01.glb",
|
||||
"size_before_kb": 121,
|
||||
"size_after_kb": 125
|
||||
},
|
||||
{
|
||||
"file": "comical_luchador_01.glb",
|
||||
"size_before_kb": 159,
|
||||
"size_after_kb": 163
|
||||
},
|
||||
{
|
||||
"file": "man_athletic_01.glb",
|
||||
"size_before_kb": 429,
|
||||
"size_after_kb": 288
|
||||
},
|
||||
{
|
||||
"file": "man_bouncer_01.glb",
|
||||
"size_before_kb": 183,
|
||||
"size_after_kb": 187
|
||||
},
|
||||
{
|
||||
"file": "man_casual_01.glb",
|
||||
"size_before_kb": 179,
|
||||
"size_after_kb": 184
|
||||
},
|
||||
{
|
||||
"file": "man_casual_02.glb",
|
||||
"size_before_kb": 203,
|
||||
"size_after_kb": 200
|
||||
},
|
||||
{
|
||||
"file": "man_casual_03.glb",
|
||||
"size_before_kb": 220,
|
||||
"size_after_kb": 197
|
||||
},
|
||||
{
|
||||
"file": "man_dj_streetwear_01.glb",
|
||||
"size_before_kb": 291,
|
||||
"size_after_kb": 239
|
||||
},
|
||||
{
|
||||
"file": "man_elder_01.glb",
|
||||
"size_before_kb": 200,
|
||||
"size_after_kb": 203
|
||||
},
|
||||
{
|
||||
"file": "man_grunge_01.glb",
|
||||
"size_before_kb": 402,
|
||||
"size_after_kb": 277
|
||||
},
|
||||
{
|
||||
"file": "man_soldier_ww2_01.glb",
|
||||
"size_before_kb": 326,
|
||||
"size_after_kb": 244
|
||||
},
|
||||
{
|
||||
"file": "man_suit_01.glb",
|
||||
"size_before_kb": 436,
|
||||
"size_after_kb": 303
|
||||
},
|
||||
{
|
||||
"file": "man_worker_hivis_01.glb",
|
||||
"size_before_kb": 463,
|
||||
"size_after_kb": 279
|
||||
},
|
||||
{
|
||||
"file": "woman_athleisure_01.glb",
|
||||
"size_before_kb": 395,
|
||||
"size_after_kb": 267
|
||||
},
|
||||
{
|
||||
"file": "woman_business_01.glb",
|
||||
"size_before_kb": 357,
|
||||
"size_after_kb": 255
|
||||
},
|
||||
{
|
||||
"file": "woman_casual_01.glb",
|
||||
"size_before_kb": 351,
|
||||
"size_after_kb": 396
|
||||
},
|
||||
{
|
||||
"file": "woman_casual_02.glb",
|
||||
"size_before_kb": 390,
|
||||
"size_after_kb": 284
|
||||
},
|
||||
{
|
||||
"file": "woman_chef_01.glb",
|
||||
"size_before_kb": 336,
|
||||
"size_after_kb": 339
|
||||
},
|
||||
{
|
||||
"file": "woman_smart_01.glb",
|
||||
"size_before_kb": 397,
|
||||
"size_after_kb": 251
|
||||
}
|
||||
],
|
||||
"fleet_draws_before": 92,
|
||||
"fleet_draws_after": 19,
|
||||
"near_cap_before": 116,
|
||||
"near_cap_after": 24
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/listening-booth.glb",
|
||||
"file": "procity_fit_listening_booth_01.glb",
|
||||
"out": "pipeline/_normalized/procity_fit_listening_booth_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_listening_booth_01.png",
|
||||
"tri_before": 53502,
|
||||
"tri_after": 7986,
|
||||
"size_m": [
|
||||
0.876,
|
||||
1.595,
|
||||
0.703
|
||||
],
|
||||
"footprint": [
|
||||
0.88,
|
||||
0.7
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drinks-fridge.glb",
|
||||
"file": "procity_fit_drinks_fridge_01.glb",
|
||||
"out": "pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_drinks_fridge_01.png",
|
||||
"tri_before": 77047,
|
||||
"tri_after": 8000,
|
||||
"size_m": [
|
||||
0.842,
|
||||
1.591,
|
||||
0.932
|
||||
],
|
||||
"footprint": [
|
||||
0.84,
|
||||
0.93
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/arcade-cabinet.glb",
|
||||
"file": "procity_fit_arcade_cabinet_01.glb",
|
||||
"out": "pipeline/_normalized/procity_fit_arcade_cabinet_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_arcade_cabinet_01.png",
|
||||
"tri_before": 25142,
|
||||
"tri_after": 8000,
|
||||
"size_m": [
|
||||
0.749,
|
||||
1.692,
|
||||
0.85
|
||||
],
|
||||
"footprint": [
|
||||
0.75,
|
||||
0.85
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/milkshake-mixer.glb",
|
||||
"file": "procity_fit_milkshake_mixer_01.glb",
|
||||
"out": "pipeline/_normalized/procity_fit_milkshake_mixer_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_milkshake_mixer_01.png",
|
||||
"tri_before": 73492,
|
||||
"tri_after": 7991,
|
||||
"size_m": [
|
||||
0.378,
|
||||
0.498,
|
||||
0.268
|
||||
],
|
||||
"footprint": [
|
||||
0.38,
|
||||
0.27
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/novelty-record.glb",
|
||||
"file": "procity_street_novelty_record_01.glb",
|
||||
"out": "pipeline/_normalized/procity_street_novelty_record_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_street_novelty_record_01.png",
|
||||
"tri_before": 26486,
|
||||
"tri_after": 8000,
|
||||
"size_m": [
|
||||
1.896,
|
||||
1.992,
|
||||
0.933
|
||||
],
|
||||
"footprint": [
|
||||
1.9,
|
||||
0.93
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/_m_counter3/trellis2_counter_till_v3.glb",
|
||||
"file": "procity_fit_counter_till_01.glb",
|
||||
"out": "/tmp/counter_norm/procity_fit_counter_till_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_counter_till_01.png",
|
||||
"tri_before": 32226,
|
||||
"tri_after": 7999,
|
||||
"size_m": [
|
||||
1.608,
|
||||
1.146,
|
||||
0.606
|
||||
],
|
||||
"footprint": [
|
||||
1.61,
|
||||
0.61
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "procity_fit_glass_case_01.glb",
|
||||
"tri_before": 40000,
|
||||
"tri_after": 7999,
|
||||
"size_m": [
|
||||
1.085,
|
||||
0.997,
|
||||
1.418
|
||||
],
|
||||
"footprint": [
|
||||
1.08,
|
||||
1.42
|
||||
],
|
||||
"thumb": "/tmp/fal_bake/thumbs/procity_fit_glass_case_01.png",
|
||||
"out": "/tmp/fal_bake/procity_fit_glass_case_01.glb"
|
||||
},
|
||||
{
|
||||
"file": "procity_street_bus_shelter_01.glb",
|
||||
"tri_before": 40000,
|
||||
"tri_after": 7998,
|
||||
"size_m": [
|
||||
2.333,
|
||||
2.4,
|
||||
1.414
|
||||
],
|
||||
"footprint": [
|
||||
2.33,
|
||||
1.41
|
||||
],
|
||||
"thumb": "/tmp/fal_bake/thumbs/procity_street_bus_shelter_01.png",
|
||||
"out": "/tmp/fal_bake/procity_street_bus_shelter_01.glb"
|
||||
},
|
||||
{
|
||||
"file": "procity_fit_magazine_rack_01.glb",
|
||||
"tri_before": 40000,
|
||||
"tri_after": 8000,
|
||||
"size_m": [
|
||||
0.545,
|
||||
1.2,
|
||||
0.443
|
||||
],
|
||||
"footprint": [
|
||||
0.54,
|
||||
0.44
|
||||
],
|
||||
"thumb": "/tmp/fal_bake/thumbs/procity_fit_magazine_rack_01.png",
|
||||
"out": "/tmp/fal_bake/procity_fit_magazine_rack_01.glb"
|
||||
},
|
||||
{
|
||||
"src": "/tmp/council-bin.glb",
|
||||
"file": "procity_street_bin_01.glb",
|
||||
"out": "/tmp/e2_norm/procity_street_bin_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_street_bin_01.png",
|
||||
"tri_before": 3000,
|
||||
"tri_after": 3000,
|
||||
"size_m": [
|
||||
0.569,
|
||||
1.0,
|
||||
0.683
|
||||
],
|
||||
"footprint": [
|
||||
0.57,
|
||||
0.68
|
||||
]
|
||||
},
|
||||
{
|
||||
"src": "/tmp/milk-crate-stack.glb",
|
||||
"file": "procity_fit_crate_stack_01.glb",
|
||||
"out": "/tmp/e2_norm/procity_fit_crate_stack_01.glb",
|
||||
"thumb": "web/assets/thumbs/procity_fit_crate_stack_01.png",
|
||||
"tri_before": 3000,
|
||||
"tri_after": 3000,
|
||||
"size_m": [
|
||||
0.71,
|
||||
0.9,
|
||||
0.721
|
||||
],
|
||||
"footprint": [
|
||||
0.71,
|
||||
0.72
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@ -1,29 +1,18 @@
|
||||
[
|
||||
"procity_fit_arcade_cabinet_01.glb",
|
||||
"procity_fit_bookshelf_01.glb",
|
||||
"procity_fit_box_crate_01.glb",
|
||||
"procity_fit_clothes_rack_01.glb",
|
||||
"procity_fit_coffee_table_01.glb",
|
||||
"procity_fit_counter_01.glb",
|
||||
"procity_fit_counter_till_01.glb",
|
||||
"procity_fit_crate_stack_01.glb",
|
||||
"procity_fit_cube_shelf_wide_01.glb",
|
||||
"procity_fit_drinks_fridge_01.glb",
|
||||
"procity_fit_glass_case_01.glb",
|
||||
"procity_fit_listening_booth_01.glb",
|
||||
"procity_fit_magazine_rack_01.glb",
|
||||
"procity_fit_milkshake_mixer_01.glb",
|
||||
"procity_fit_record_crate_01.glb",
|
||||
"procity_fit_wire_shelf_01.glb",
|
||||
"procity_fit_work_table_01.glb",
|
||||
"procity_street_bench_01.glb",
|
||||
"procity_street_bench_modern_01.glb",
|
||||
"procity_street_bench_wood_01.glb",
|
||||
"procity_street_bin_01.glb",
|
||||
"procity_street_bus_shelter_01.glb",
|
||||
"procity_street_food_cart_01.glb",
|
||||
"procity_street_longbench_01.glb",
|
||||
"procity_street_novelty_record_01.glb",
|
||||
"procity_street_park_bench_01.glb",
|
||||
"procity_street_streetlight_01.glb"
|
||||
]
|
||||
@ -1,224 +0,0 @@
|
||||
"""PROCITY hero-prop bake-to-lowpoly — turn a heavy TRELLIS shell-soup GLB into a game-ready
|
||||
≤8k-tri prop with its base colour BAKED onto fresh UVs.
|
||||
|
||||
Runs headless on this box's Blender:
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python bake_lowpoly.py -- BATCH.json OUT_DIR [THUMB_DIR]
|
||||
|
||||
Why bake (R3's lesson): raw TRELLIS meshes are dense multi-shell scans; plain decimate /
|
||||
meshopt-simplify floors at 25–77k because they can't merge across the shells. So we throw the
|
||||
topology away and rebuild it:
|
||||
1. import the normalized HP (already metres / +Y up / base-origin / textured) → bake SOURCE
|
||||
2. voxel-remesh a copy → one watertight manifold surface (no more shell-soup)
|
||||
3. decimate the remesh to the tri budget (≤8k) → LP
|
||||
4. smart-UV unwrap LP
|
||||
5. Cycles bake DIFFUSE(COLOR) HP → LP's new UVs → a single baked base-colour texture
|
||||
6. export LP GLB (WebP, embedded, self-illuminated a touch like normalize.py) + 256 thumbnail
|
||||
|
||||
BATCH.json: [ {"src":"pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
|
||||
"id":"drinks_fridge", "category":"fit",
|
||||
"voxel":0.012, "tris":8000, "img":1024} , ... ]
|
||||
"""
|
||||
import bpy, sys, os, json, math
|
||||
from mathutils import Vector
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
BATCH, OUT_DIR = ARGV[0], (ARGV[1] if len(ARGV) > 1 else "/tmp/procity_bake")
|
||||
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
|
||||
os.makedirs(OUT_DIR, exist_ok=True); os.makedirs(THUMB_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def wipe():
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
|
||||
for d in list(coll):
|
||||
if d.users == 0:
|
||||
coll.remove(d)
|
||||
|
||||
|
||||
def import_join(src):
|
||||
bpy.ops.import_scene.gltf(filepath=src)
|
||||
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
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
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 make_lowpoly(hp, voxel, tris):
|
||||
"""Duplicate HP → voxel remesh → decimate to budget → smart UV. Returns the LP object."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
hp.select_set(True); bpy.context.view_layer.objects.active = hp
|
||||
bpy.ops.object.duplicate()
|
||||
lp = bpy.context.view_layer.objects.active
|
||||
lp.name = "LP"
|
||||
# strip HP's materials off the LP — it gets one fresh baked material
|
||||
lp.data.materials.clear()
|
||||
rm = lp.modifiers.new("remesh", 'REMESH')
|
||||
rm.mode = 'VOXEL'; rm.voxel_size = voxel; rm.adaptivity = 0.0
|
||||
bpy.ops.object.modifier_apply(modifier=rm.name)
|
||||
# triangulate so len(polygons) == triangle count → the decimate ratio targets triangles honestly
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.quads_convert_to_tris()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
n = len(lp.data.polygons)
|
||||
if n > tris:
|
||||
dm = lp.modifiers.new("dec", 'DECIMATE')
|
||||
dm.ratio = max(0.005, tris / n)
|
||||
bpy.ops.object.modifier_apply(modifier=dm.name)
|
||||
# UVs
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.uv.smart_project(island_margin=0.02, angle_limit=math.radians(66))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
return lp
|
||||
|
||||
|
||||
def _base_color_to_emission(hp):
|
||||
"""Route every HP material's base-colour (texture or flat value) into its Emission and mute the
|
||||
BSDF, so a bake_type='EMIT' pass reads PURE ALBEDO regardless of the source material setup
|
||||
(TRELLIS and fal-Hunyuan wire their materials differently; DIFFUSE(COLOR) mis-reads fal's)."""
|
||||
for m in hp.data.materials:
|
||||
if not m or not m.use_nodes:
|
||||
continue
|
||||
nt = m.node_tree
|
||||
b = nt.nodes.get("Principled BSDF")
|
||||
if not b:
|
||||
continue
|
||||
bc, ec = b.inputs.get("Base Color"), (b.inputs.get("Emission Color") or b.inputs.get("Emission"))
|
||||
es = b.inputs.get("Emission Strength")
|
||||
if ec is None:
|
||||
continue
|
||||
if bc and bc.is_linked:
|
||||
nt.links.new(bc.links[0].from_socket, ec)
|
||||
elif bc:
|
||||
ec.default_value = bc.default_value
|
||||
if es is not None:
|
||||
es.default_value = 1.0
|
||||
|
||||
|
||||
def bake_color(hp, lp, img_size):
|
||||
_base_color_to_emission(hp)
|
||||
img = bpy.data.images.new("baked", img_size, img_size)
|
||||
mat = bpy.data.materials.new("baked_mat"); mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = nt.nodes.get("Principled BSDF")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
lp.data.materials.clear(); lp.data.materials.append(mat)
|
||||
nt.nodes.active = tex # the active image node = bake target
|
||||
|
||||
scn = bpy.context.scene
|
||||
scn.render.engine = 'CYCLES'
|
||||
try:
|
||||
scn.cycles.device = 'GPU'
|
||||
except Exception:
|
||||
pass
|
||||
scn.cycles.samples = 4
|
||||
scn.render.bake.use_selected_to_active = True
|
||||
scn.render.bake.cage_extrusion = 0.06
|
||||
scn.render.bake.max_ray_distance = 0.12
|
||||
scn.render.bake.margin = max(4, img_size // 128)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
hp.select_set(True); lp.select_set(True)
|
||||
bpy.context.view_layer.objects.active = lp # active = bake TARGET
|
||||
bpy.ops.object.bake(type='EMIT', use_clear=True) # HP base-colour routed to emission = albedo
|
||||
|
||||
# self-illuminate a touch so it reads in any viewer (matches normalize.py bright)
|
||||
ec = bsdf.inputs.get("Emission Color") or bsdf.inputs.get("Emission")
|
||||
if ec is not None:
|
||||
nt.links.new(tex.outputs["Color"], ec)
|
||||
es = bsdf.inputs.get("Emission Strength")
|
||||
if es is not None:
|
||||
es.default_value = 0.28
|
||||
return img
|
||||
|
||||
|
||||
def base_origin(obj):
|
||||
mn, mx = bounds(obj)
|
||||
obj.location -= Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
|
||||
bpy.ops.object.transform_apply(location=True)
|
||||
|
||||
|
||||
def render_thumb(lp, png_name):
|
||||
scn = bpy.context.scene
|
||||
mn, mx = bounds(lp); 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))
|
||||
cam.rotation_euler = (ctr - cam.location).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 bake_one(spec):
|
||||
wipe()
|
||||
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
|
||||
hp = import_join(src)
|
||||
tri_before = len(hp.data.polygons)
|
||||
lp = make_lowpoly(hp, spec.get("voxel", 0.02), spec.get("tris", 8000))
|
||||
bake_color(hp, lp, spec.get("img", 1024))
|
||||
bpy.data.objects.remove(hp, do_unlink=True) # HP done; export LP only
|
||||
base_origin(lp)
|
||||
lp.select_set(True); bpy.context.view_layer.objects.active = lp
|
||||
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,
|
||||
use_selection=True, export_image_format='WEBP', export_image_quality=88)
|
||||
thumb = render_thumb(lp, name.replace(".glb", ".png"))
|
||||
mn, mx = bounds(lp)
|
||||
return {"file": name, "tri_before": tri_before, "tri_after": len(lp.data.polygons),
|
||||
"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)], "thumb": thumb, "out": out}
|
||||
|
||||
|
||||
def main():
|
||||
specs = json.load(open(BATCH))
|
||||
results, errors = [], []
|
||||
for spec in specs:
|
||||
try:
|
||||
r = bake_one(spec)
|
||||
results.append(r)
|
||||
print(f"OK {r['file']:36} {r['tri_before']:>6}→{r['tri_after']:>5} tris "
|
||||
f"footprint {r['footprint']} h {r['size_m'][1]}")
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
errors.append({"id": spec.get("id"), "error": str(e)})
|
||||
print(f"ERR {spec.get('id')}: {e}")
|
||||
json.dump({"results": results, "errors": errors},
|
||||
open(os.path.join(OUT_DIR, "_bake_results.json"), "w"), indent=2)
|
||||
print(f"\nBAKE_DONE ok={len(results)} err={len(errors)} → {OUT_DIR}")
|
||||
|
||||
|
||||
main()
|
||||
@ -1,50 +0,0 @@
|
||||
[
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
|
||||
"id": "drinks_fridge",
|
||||
"category": "fit",
|
||||
"voxel": 0.03,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_fit_milkshake_mixer_01.glb",
|
||||
"id": "milkshake_mixer",
|
||||
"category": "fit",
|
||||
"voxel": 0.005,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_fit_arcade_cabinet_01.glb",
|
||||
"id": "arcade_cabinet",
|
||||
"category": "fit",
|
||||
"voxel": 0.012,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_fit_listening_booth_01.glb",
|
||||
"id": "listening_booth",
|
||||
"category": "fit",
|
||||
"voxel": 0.012,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_street_novelty_record_01.glb",
|
||||
"id": "novelty_record",
|
||||
"category": "street",
|
||||
"voxel": 0.014,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/_normalized/procity_fit_counter_till_01.glb",
|
||||
"id": "counter_till",
|
||||
"category": "fit",
|
||||
"voxel": 0.012,
|
||||
"tris": 8000,
|
||||
"img": 1024
|
||||
}
|
||||
]
|
||||
@ -1,3 +0,0 @@
|
||||
[
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/_m_counter3/trellis2_counter_till_v3.glb", "id": "counter_till", "category": "fit", "height_m": 1.15, "tris": 8000}
|
||||
]
|
||||
@ -1,4 +0,0 @@
|
||||
[
|
||||
{"src": "/tmp/council-bin.glb", "id": "bin", "category": "street", "height_m": 1.0, "tris": 5000},
|
||||
{"src": "/tmp/milk-crate-stack.glb","id": "crate_stack", "category": "fit", "height_m": 0.9, "tris": 5000}
|
||||
]
|
||||
@ -1,5 +0,0 @@
|
||||
[
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/glass_case_fal1.glb", "id": "glass_case", "category": "fit", "height_m": 1.0, "tris": 60000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bus_shelter_fal2.glb", "id": "bus_shelter", "category": "street", "height_m": 2.4, "tris": 60000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/magazine_rack_fal1.glb", "id": "magazine_rack", "category": "fit", "height_m": 1.2, "tris": 60000}
|
||||
]
|
||||
@ -1,97 +0,0 @@
|
||||
[
|
||||
{
|
||||
"src": "pipeline/.peds_r6/comical_boy_01.glb",
|
||||
"name": "comical_boy_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/comical_luchador_01.glb",
|
||||
"name": "comical_luchador_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_athletic_01.glb",
|
||||
"name": "man_athletic_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_bouncer_01.glb",
|
||||
"name": "man_bouncer_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_casual_01.glb",
|
||||
"name": "man_casual_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_casual_02.glb",
|
||||
"name": "man_casual_02",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_casual_03.glb",
|
||||
"name": "man_casual_03",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_dj_streetwear_01.glb",
|
||||
"name": "man_dj_streetwear_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_elder_01.glb",
|
||||
"name": "man_elder_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_grunge_01.glb",
|
||||
"name": "man_grunge_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_soldier_ww2_01.glb",
|
||||
"name": "man_soldier_ww2_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_suit_01.glb",
|
||||
"name": "man_suit_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/man_worker_hivis_01.glb",
|
||||
"name": "man_worker_hivis_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_athleisure_01.glb",
|
||||
"name": "woman_athleisure_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_business_01.glb",
|
||||
"name": "woman_business_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_casual_01.glb",
|
||||
"name": "woman_casual_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_casual_02.glb",
|
||||
"name": "woman_casual_02",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_chef_01.glb",
|
||||
"name": "woman_chef_01",
|
||||
"atlas": 1024
|
||||
},
|
||||
{
|
||||
"src": "pipeline/.peds_r6/woman_smart_01.glb",
|
||||
"name": "woman_smart_01",
|
||||
"atlas": 1024
|
||||
}
|
||||
]
|
||||
@ -1,10 +0,0 @@
|
||||
[
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/listening-booth.glb", "id": "listening_booth", "category": "fit", "height_m": 1.6, "tris": 6000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/glass-case.glb", "id": "glass_case", "category": "fit", "height_m": 1.0, "tris": 6000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drinks-fridge.glb", "id": "drinks_fridge", "category": "fit", "height_m": 1.6, "tris": 5000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/magazine-rack.glb", "id": "magazine_rack", "category": "fit", "height_m": 1.2, "tris": 4000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/arcade-cabinet.glb", "id": "arcade_cabinet", "category": "fit", "height_m": 1.7, "tris": 6000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/milkshake-mixer.glb", "id": "milkshake_mixer", "category": "fit", "height_m": 0.5, "tris": 4000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/novelty-record.glb", "id": "novelty_record", "category": "street", "height_m": 2.0, "tris": 4000},
|
||||
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bus-shelter.glb", "id": "bus_shelter", "category": "street", "height_m": 2.4, "tris": 8000}
|
||||
]
|
||||
@ -13,19 +13,10 @@ 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") # library GLBs
|
||||
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
|
||||
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json")
|
||||
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
||||
DEPOT = "https://digalot.fyi/3god"
|
||||
|
||||
# ── depot-reuse: already-published GLBs we point at without a local normalize. The round-3 reuse
|
||||
# check found vintage-cash-register.glb live on 3GOD (from thriftgod_hero) — reuse it for the
|
||||
# universal till instead of re-generating. Manual footprint/height (no local file to measure);
|
||||
# no local thumb, so it isn't staged for ?localdepot (falls back to primitive there). ──
|
||||
DEPOT_REUSE = {
|
||||
"cash_register": {"file": "vintage-cash-register.glb", "footprint": [0.35, 0.28], "height": 0.35},
|
||||
}
|
||||
|
||||
# ── 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
|
||||
@ -142,14 +133,13 @@ def build():
|
||||
"awning": awning,
|
||||
}
|
||||
|
||||
# ---- fittings / furniture from the normalizer results (library + round-3 hero props) ----
|
||||
# ---- 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"}
|
||||
for results_file in (RESULTS, PROPS_RESULTS):
|
||||
if not os.path.exists(results_file):
|
||||
continue
|
||||
for r in json.load(open(results_file)).get("results", []):
|
||||
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", "")
|
||||
@ -160,22 +150,15 @@ def build():
|
||||
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
|
||||
}
|
||||
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
|
||||
# depot-reuse assets (no local normalize) — e.g. the reused vintage cash register
|
||||
for fid, meta in DEPOT_REUSE.items():
|
||||
fittings.setdefault(fid, dict(meta))
|
||||
|
||||
# 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 = {
|
||||
# registry 'counter' → the ~2m counter-with-till (counter_till), NOT the 4m balcao 'counter'
|
||||
"record_bins": "record_crate", "crates": "box_crate", "counter": "counter_till",
|
||||
"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",
|
||||
# round-5 fal/MODELBEAST hero props → their registry semantic ids
|
||||
"glass_case": "glass_case", "glass_cabinets": "glass_case", "magazine_rack": "magazine_rack",
|
||||
"fridge": "drinks_fridge", "listening_corner": "listening_booth",
|
||||
}
|
||||
fitting_aliases = {k: v for k, v in fitting_aliases.items() if v in fittings}
|
||||
|
||||
|
||||
@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY GODVERSE stock pack — real record sleeves for C's dig (?stock=real), round-7 E2.
|
||||
|
||||
Build-time only: reads the local recordgod dataset (dealgod Postgres `discogs_release`, the
|
||||
185k-vinyl set), curates ~200-400 sleeves, **parody-transforms titles/artists deterministically**
|
||||
(the no-real-trademarks law covers metadata), fetches cover images, bakes atlas sheets + a JSON
|
||||
index, publishes atlases to the depot. NOTHING queries the dataset at runtime — C reads the built
|
||||
pack (index + atlases) through the depot / ?localdepot=1.
|
||||
|
||||
python3 build_stock_pack.py --from-db --count 300 # real pack (needs GODVERSE_DSN + net for covers)
|
||||
python3 build_stock_pack.py --sample 24 # synthetic pipeline test (no DB, solid-colour sleeves)
|
||||
python3 build_stock_pack.py --publish # push built atlases + index to the depot
|
||||
|
||||
Index schema (proposed to Lane C in LANE_E_NOTES — adjust `INDEX_FIELDS` to their contract):
|
||||
{ "version":1, "atlas_px":2048, "cell":256, "items":[
|
||||
{"id","title","artist","price","price_band","atlas","uv":[u0,v0,u1,v1]} ] }
|
||||
|
||||
Parody law: real (title,artist) → deterministic seed → parody title/artist recombined from the
|
||||
word banks below. The output shares NO tokens with the source, so zero real-trademark leakage,
|
||||
yet reads as period-plausible AU record-shop stock. Same source row → same parody, every build.
|
||||
"""
|
||||
import json, os, sys, struct, hashlib, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(ROOT, "pipeline", "_stockpack")
|
||||
STAGE = os.path.join(ROOT, "web", "assets", "models") # ?localdepot mirror
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
INDEX_FIELDS = ("id", "title", "artist", "price", "price_band", "atlas", "uv")
|
||||
|
||||
# ── parody word banks (period-plausible AU record stock; no real acts) ──────────────────
|
||||
ADJ = ["Midnight", "Electric", "Velvet", "Golden", "Crimson", "Neon", "Sunburnt", "Hazy",
|
||||
"Concrete", "Paper", "Silver", "Restless", "Quiet", "Broken", "Endless", "Salt"]
|
||||
NOUN = ["Sessions", "Highway", "Ballroom", "Suburbs", "Tide", "Radio", "Static", "Harvest",
|
||||
"Verandah", "Jetty", "Milk Bar", "Cassette", "Reunion", "Departure", "Anthems", "Blue"]
|
||||
BAND_A = ["The", "Big", "Lonesome", "Riptide", "Powderfinger-ish", "Sunset", "Regional", "Mullet"]
|
||||
BAND_B = ["Galahs", "Dropbears", "Hills Hoists", "Milk Crates", "Southerlies", "Panelvans",
|
||||
"Rissoles", "Servos", "Bottlebrush", "Sandgropers", "Cockatoos", "Utes"]
|
||||
FIRST = ["Bazza", "Shazza", "Kylie", "Darryl", "Noeline", "Trev", "Merv", "Dazza", "Coral", "Kev"]
|
||||
LAST = ["Studebaker", "Fitzroy", "Nullarbor", "Grong", "Wollemi", "Barcoo", "Tuckerbox", "Yakka"]
|
||||
|
||||
|
||||
def _seed(*parts):
|
||||
return int(hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()[:12], 16)
|
||||
|
||||
|
||||
def parody(title, artist, is_group=None):
|
||||
"""Deterministic parody of a real (title, artist). Shares no source tokens."""
|
||||
s = _seed(title or "", artist or "")
|
||||
ptitle = f"{ADJ[s % len(ADJ)]} {NOUN[(s // 7) % len(NOUN)]}"
|
||||
if (s // 13) % 5 == 0:
|
||||
ptitle += f", Vol. {1 + (s // 3) % 3}"
|
||||
# solo vs group name, deterministically
|
||||
if is_group is None:
|
||||
is_group = (s % 2 == 0)
|
||||
if is_group:
|
||||
partist = f"{BAND_A[(s // 5) % len(BAND_A)]} {BAND_B[(s // 11) % len(BAND_B)]}".strip()
|
||||
else:
|
||||
partist = f"{FIRST[(s // 17) % len(FIRST)]} {LAST[(s // 19) % len(LAST)]}"
|
||||
return ptitle, partist
|
||||
|
||||
|
||||
def price_band(price):
|
||||
if price is None:
|
||||
return "bargain"
|
||||
if price < 8:
|
||||
return "bargain"
|
||||
if price < 25:
|
||||
return "standard"
|
||||
if price < 60:
|
||||
return "collector"
|
||||
return "grail"
|
||||
|
||||
|
||||
# ── data sources ────────────────────────────────────────────────────────────────────────
|
||||
def rows_from_db(count):
|
||||
"""Curate `count` sleeves from dealgod's discogs_release (needs GODVERSE_DSN + psycopg2).
|
||||
Picks rows WITH a cover image + non-empty artist/title, varied by decade for shelf variety."""
|
||||
import psycopg2 # noqa: build-time only
|
||||
dsn = os.environ.get("GODVERSE_DSN") or os.environ.get("DB_DSN")
|
||||
if not dsn:
|
||||
raise SystemExit("GODVERSE_DSN not set — the DB dsn (creds) is required for --from-db. "
|
||||
"Ask John / read from dealgod's env. (Nothing queries the DB at runtime.)")
|
||||
con = psycopg2.connect(dsn)
|
||||
cur = con.cursor()
|
||||
cur.execute("""
|
||||
SELECT release_id, artist, title, year,
|
||||
(SELECT current_price FROM prices p WHERE p.release_id=dr.release_id
|
||||
ORDER BY current_price LIMIT 1) AS price,
|
||||
images
|
||||
FROM discogs_release dr
|
||||
WHERE artist <> '' AND title <> '' AND images IS NOT NULL AND images <> '[]'
|
||||
ORDER BY md5(release_id::text) -- deterministic pseudo-random sample
|
||||
LIMIT %s""", (count,))
|
||||
out = []
|
||||
for rid, artist, title, year, price, images in cur.fetchall():
|
||||
imgs = images if isinstance(images, list) else json.loads(images or "[]")
|
||||
url = imgs[0].get("uri") if imgs and isinstance(imgs[0], dict) else (imgs[0] if imgs else None)
|
||||
if url:
|
||||
out.append({"src_id": rid, "artist": artist, "title": title, "price": price, "img": url})
|
||||
con.close()
|
||||
return out
|
||||
|
||||
|
||||
def rows_sample(n):
|
||||
"""Synthetic rows for a no-DB pipeline test — solid-colour 'sleeves', fake source metadata."""
|
||||
from PIL import Image, ImageDraw
|
||||
rows = []
|
||||
os.makedirs(os.path.join(OUT, "_imgs"), exist_ok=True)
|
||||
for i in range(n):
|
||||
s = _seed("sample", i)
|
||||
col = ((s >> 0) & 255, (s >> 8) & 255, (s >> 16) & 255)
|
||||
im = Image.new("RGB", (256, 256), col)
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rectangle([16, 16, 240, 240], outline=(255, 255, 255), width=3)
|
||||
d.ellipse([88, 88, 168, 168], fill=(20, 20, 20)) # a 'record' centre
|
||||
p = os.path.join(OUT, "_imgs", f"s{i}.png")
|
||||
im.save(p)
|
||||
rows.append({"src_id": f"sample-{i}", "artist": f"Real Artist {i}",
|
||||
"title": f"Real Album {i}", "price": 5 + (s % 80), "img": p})
|
||||
return rows
|
||||
|
||||
|
||||
# ── atlas builder ───────────────────────────────────────────────────────────────────────
|
||||
def build_pack(rows, cell=256, per_atlas_side=8):
|
||||
from PIL import Image
|
||||
per = per_atlas_side * per_atlas_side
|
||||
items, atlases = [], []
|
||||
for ai in range((len(rows) + per - 1) // per):
|
||||
chunk = rows[ai * per:(ai + 1) * per]
|
||||
sheet = Image.new("RGB", (cell * per_atlas_side, cell * per_atlas_side), (30, 30, 34))
|
||||
aname = f"stock_record_atlas_{ai:02d}.webp"
|
||||
for j, r in enumerate(chunk):
|
||||
gx, gy = j % per_atlas_side, j // per_atlas_side
|
||||
try:
|
||||
cov = _load_img(r["img"]).resize((cell, cell))
|
||||
except Exception:
|
||||
continue
|
||||
sheet.paste(cov, (gx * cell, gy * cell))
|
||||
ptitle, partist = parody(r["title"], r["artist"])
|
||||
u0, v0 = gx / per_atlas_side, gy / per_atlas_side
|
||||
item = {"id": f"rec_{len(items):04d}", "title": ptitle, "artist": partist,
|
||||
"price": int(r["price"] or 5), "price_band": price_band(r["price"]),
|
||||
"atlas": aname, "uv": [round(u0, 4), round(v0, 4),
|
||||
round(u0 + 1 / per_atlas_side, 4), round(v0 + 1 / per_atlas_side, 4)]}
|
||||
items.append({k: item[k] for k in INDEX_FIELDS})
|
||||
sheet.save(os.path.join(OUT, aname), "WEBP", quality=88)
|
||||
atlases.append(aname)
|
||||
index = {"version": 1, "atlas_px": cell * per_atlas_side, "cell": cell,
|
||||
"count": len(items), "atlases": atlases, "items": items}
|
||||
json.dump(index, open(os.path.join(OUT, "stock_record_index.json"), "w"), indent=1)
|
||||
return index
|
||||
|
||||
|
||||
def _load_img(src):
|
||||
from PIL import Image
|
||||
import io
|
||||
if src.startswith("http"):
|
||||
req = urllib.request.Request(src, headers={"User-Agent": "procity-stockpack/1"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
||||
return Image.open(src).convert("RGB")
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv
|
||||
if "--publish" in a:
|
||||
# atlases + index publish through the same depot path as GLBs (documented; run when built)
|
||||
for f in os.listdir(OUT):
|
||||
if f.endswith(".webp") or f == "stock_record_index.json":
|
||||
os.makedirs(STAGE, exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy(os.path.join(OUT, f), os.path.join(STAGE, f))
|
||||
print(f"staged pack → {STAGE} (for ?localdepot). Depot push: adapt publish.py to POST "
|
||||
f"{OUT}/*.webp + index (same /api/upload path).")
|
||||
return
|
||||
if "--sample" in a:
|
||||
n = int(a[a.index("--sample") + 1])
|
||||
rows = rows_sample(n)
|
||||
elif "--from-db" in a:
|
||||
count = int(a[a.index("--count") + 1]) if "--count" in a else 300
|
||||
rows = rows_from_db(count)
|
||||
else:
|
||||
print(__doc__); return
|
||||
idx = build_pack(rows)
|
||||
print(f"built pack: {idx['count']} sleeves, {len(idx['atlases'])} atlas(es) → {OUT}")
|
||||
print(f" sample items: {[(i['title'], i['artist'], i['price_band']) for i in idx['items'][:3]]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair a GLB whose material references a texture with no image source.
|
||||
|
||||
Blender's WebP glTF export occasionally emits a texture slot whose image failed to encode
|
||||
(no `source`, no `EXT_texture_webp.source`) — e.g. a packed metallic-roughness/AO map. three.js
|
||||
GLTFLoader then throws `Cannot read properties of undefined (reading 'uri')` when a material points
|
||||
at it. This strips every material texture-slot that points at a sourceless texture (base-colour /
|
||||
normal are kept; the orphaned texture is never loaded because GLTFLoader loads textures lazily),
|
||||
then repacks the GLB (BIN chunk untouched).
|
||||
|
||||
python3 fix_glb_textures.py in.glb out.glb
|
||||
"""
|
||||
import json, struct, sys
|
||||
|
||||
SLOTS = ("baseColorTexture", "metallicRoughnessTexture", "normalTexture",
|
||||
"occlusionTexture", "emissiveTexture")
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
d = open(path, "rb").read()
|
||||
off, gltf, chunks = 12, None, []
|
||||
while off < len(d):
|
||||
clen, ctype = struct.unpack("<II", d[off:off + 8])
|
||||
body = d[off + 8: off + 8 + clen]
|
||||
if ctype == 0x4E4F534A:
|
||||
gltf = json.loads(body.decode("utf-8"))
|
||||
chunks.append((ctype, body))
|
||||
off += 8 + clen
|
||||
return gltf, chunks
|
||||
|
||||
|
||||
def has_source(tex):
|
||||
if "source" in tex:
|
||||
return True
|
||||
ext = tex.get("extensions", {})
|
||||
return any("source" in v for v in ext.values() if isinstance(v, dict))
|
||||
|
||||
|
||||
def repair(gltf):
|
||||
textures = gltf.get("textures", [])
|
||||
bad = {i for i, t in enumerate(textures) if not has_source(t)}
|
||||
if not bad:
|
||||
return 0
|
||||
stripped = 0
|
||||
for m in gltf.get("materials", []):
|
||||
pbr = m.get("pbrMetallicRoughness", {})
|
||||
for container in (m, pbr):
|
||||
for slot in list(container.keys()):
|
||||
if slot in SLOTS and isinstance(container[slot], dict) \
|
||||
and container[slot].get("index") in bad:
|
||||
del container[slot]
|
||||
stripped += 1
|
||||
return stripped
|
||||
|
||||
|
||||
def write_glb(gltf, chunks, out):
|
||||
new_json = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
|
||||
new_json += b" " * ((4 - len(new_json) % 4) % 4) # pad to 4 bytes
|
||||
body = struct.pack("<II", len(new_json), 0x4E4F534A) + new_json
|
||||
for ctype, data in chunks:
|
||||
if ctype == 0x4E4F534A:
|
||||
continue
|
||||
pad = b"\x00" * ((4 - len(data) % 4) % 4)
|
||||
body += struct.pack("<II", len(data) + len(pad), ctype) + data + pad
|
||||
header = struct.pack("<III", 0x46546C67, 2, 12 + len(body))
|
||||
open(out, "wb").write(header + body)
|
||||
|
||||
|
||||
def main():
|
||||
inp, out = sys.argv[1], sys.argv[2]
|
||||
gltf, chunks = read_glb(inp)
|
||||
n = repair(gltf)
|
||||
write_glb(gltf, chunks, out)
|
||||
print(f"stripped {n} material slot(s) pointing at sourceless textures → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY hero props — free, on-device (round-3 Fable path: MODELBEAST, not fal.ai).
|
||||
|
||||
Two local stages, both on the M3 Ultra GPU, zero cloud cost:
|
||||
1. concept image flux_local (FLUX.2-klein-4B) text->object photo ~6s
|
||||
2. mesh trellis_mac (TRELLIS.2-4B) image->GLB+PBR ~3-5 min (1024_cascade)
|
||||
then pipeline/normalize.py (house GLB law) + thumbnail + manifest.
|
||||
|
||||
Prop list + prompts come from pipeline/meshgod_batch.json (the same gap list that was going to
|
||||
fal.ai). cash-register is skipped — vintage-cash-register.glb already lives on the 3GOD depot.
|
||||
|
||||
python3 pipeline/gen_props.py --dry-run # list props + what's done, no GPU
|
||||
python3 pipeline/gen_props.py --concepts # stage 1 for all -> .genprops/<name>.png
|
||||
python3 pipeline/gen_props.py --concepts --only glass-case
|
||||
python3 pipeline/gen_props.py --mesh --only glass-case # stage 2 for one (concept must exist)
|
||||
python3 pipeline/gen_props.py --mesh # stage 2 for every prop with a concept, no glb
|
||||
python3 pipeline/gen_props.py --draft --only glass-case # sf3d seconds-fast draft (iterate concept)
|
||||
|
||||
Resumable: skips a stage whose output already exists in .genprops/. Eyeball .genprops/*.png (concepts)
|
||||
and the rendered GLBs before normalizing. Everything here is OPTIONAL at runtime — every prop has an
|
||||
in-engine primitive fallback (see MESHGOD_BATCH.md _primitives_instead), so nothing blocks on it.
|
||||
"""
|
||||
import json, os, sys, subprocess, shutil, time
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAW = os.path.join(ROOT, "pipeline", ".genprops")
|
||||
BATCH = os.path.join(ROOT, "pipeline", "meshgod_batch.json")
|
||||
os.makedirs(RAW, exist_ok=True)
|
||||
|
||||
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")
|
||||
TRELLIS_PY = os.path.join(MB, "vendor/trellis-mac/.venv/bin/python")
|
||||
TRELLIS_RUN = os.path.join(MB, "server/operators/trellis_mac/run.py")
|
||||
SF3D_PY = os.path.join(MB, "venvs/sf3d/bin/python")
|
||||
SF3D_RUN = os.path.join(MB, "server/operators/sf3d/run.py")
|
||||
|
||||
# Clean product-shot framing so TRELLIS/sf3d (which segment the subject) get a whole, isolated
|
||||
# object on a plain backdrop — NOT the faded op-shop facade look. Period-appropriate but neutral.
|
||||
CONCEPT = ("Clean studio product photograph of {v}. Single object, centred, entire object in "
|
||||
"frame, three-quarter view, plain seamless pale-grey backdrop, soft even lighting, "
|
||||
"sharp focus, no text, no people, no other objects, 1990s Australian period-correct.")
|
||||
|
||||
|
||||
def props():
|
||||
data = json.load(open(BATCH))
|
||||
out = []
|
||||
for a in data["assets"]:
|
||||
# cash-register: reuse the depot's vintage-cash-register.glb (round-3 reuse check)
|
||||
if a["name"] == "cash-register":
|
||||
continue
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
|
||||
def concept_path(name):
|
||||
return os.path.join(RAW, f"{name}.png")
|
||||
|
||||
|
||||
def glb_path(name):
|
||||
return os.path.join(RAW, f"{name}.glb")
|
||||
|
||||
|
||||
def gen_concept(a, seed=3):
|
||||
name, prompt = a["name"], a["prompt"]
|
||||
outdir = os.path.join(RAW, "_c_" + name)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
params = json.dumps({"prompt": CONCEPT.format(v=prompt), "model": "flux2-klein-4b",
|
||||
"steps": 4, "width": 1024, "height": 1024, "seed": seed})
|
||||
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")[-200:])
|
||||
shutil.move(os.path.join(outdir, pngs[0]), concept_path(name))
|
||||
shutil.rmtree(outdir, ignore_errors=True)
|
||||
return concept_path(name)
|
||||
|
||||
|
||||
def gen_mesh(a, draft=False):
|
||||
"""concept image -> GLB. TRELLIS 1024_cascade by default; sf3d for a fast draft."""
|
||||
name = a["name"]
|
||||
cpath = concept_path(name)
|
||||
if not os.path.exists(cpath):
|
||||
raise RuntimeError(f"no concept image for {name} — run --concepts first")
|
||||
outdir = os.path.join(RAW, "_m_" + name)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
if draft:
|
||||
params = json.dumps({"device": "mps", "texture_resolution": 1024, "foreground_ratio": 0.85})
|
||||
cmd = [SF3D_PY, SF3D_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
|
||||
tmo = 600
|
||||
else:
|
||||
params = json.dumps({"pipeline_type": "1024_cascade", "texture_size": 1024, "seed": 0})
|
||||
cmd = [TRELLIS_PY, TRELLIS_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
|
||||
tmo = 1200
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=tmo)
|
||||
glbs = []
|
||||
for dp, _, fs in os.walk(outdir):
|
||||
glbs += [os.path.join(dp, f) for f in fs if f.endswith(".glb")]
|
||||
if not glbs:
|
||||
raise RuntimeError((r.stderr or r.stdout or "no glb")[-240:])
|
||||
dst = glb_path(name)
|
||||
shutil.move(sorted(glbs)[0], dst)
|
||||
shutil.rmtree(outdir, ignore_errors=True)
|
||||
return dst
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
|
||||
draft = "--draft" in sys.argv
|
||||
todo = [a for a in props() if not only or only in a["name"]]
|
||||
|
||||
if "--dry-run" in sys.argv:
|
||||
print(f"{len(todo)} props (cash-register reused from depot):")
|
||||
for a in todo:
|
||||
c = "concept✓" if os.path.exists(concept_path(a["name"])) else "concept·"
|
||||
g = "glb✓" if os.path.exists(glb_path(a["name"])) else "glb·"
|
||||
print(f" {a['name']:16s} {c} {g} h={a['height_m']}m for={a['for']}")
|
||||
sys.exit(0)
|
||||
|
||||
do_concepts = "--concepts" in sys.argv or (not "--mesh" in sys.argv and not draft)
|
||||
do_mesh = "--mesh" in sys.argv or draft
|
||||
|
||||
if do_concepts:
|
||||
for a in todo:
|
||||
if os.path.exists(concept_path(a["name"])):
|
||||
print(f"[concept] {a['name']} — exists, skip"); continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
gen_concept(a)
|
||||
print(f"[concept] {a['name']} — {round(time.time()-t0,1)}s")
|
||||
except Exception as e:
|
||||
print(f"[concept] {a['name']} FAILED: {e}")
|
||||
|
||||
if do_mesh:
|
||||
tool = "sf3d-draft" if draft else "trellis 1024_cascade"
|
||||
for a in todo:
|
||||
if not draft and os.path.exists(glb_path(a["name"])):
|
||||
print(f"[mesh] {a['name']} — glb exists, skip"); continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
dst = gen_mesh(a, draft=draft)
|
||||
kb = os.path.getsize(dst) // 1024
|
||||
print(f"[mesh:{tool}] {a['name']} — {round(time.time()-t0,1)}s ({kb}KB)")
|
||||
except Exception as e:
|
||||
print(f"[mesh:{tool}] {a['name']} FAILED: {e}")
|
||||
print("done. Eyeball .genprops/*.png (concepts) + rendered GLBs before normalize.")
|
||||
@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""fal.ai Hunyuan3D v2 image->GLB for the two TRELLIS glass rejects (glass-case, bus-shelter).
|
||||
|
||||
Paid — John-authorized (ROUND5 decision #4; account topped up). ~14-17c/gen, <=2 attempts each,
|
||||
every gen logged to AUDIT.md by the caller. Uses the owner's FAL_KEY configured in MODELBEAST
|
||||
settings (never hardcoded here) via MODELBEAST's own settings loader — the same env-injection the
|
||||
server does. MUST run with MODELBEAST's server venv python (it has fal_client):
|
||||
|
||||
MBPY=~/Documents/MODELBEAST/.venv/bin/python
|
||||
$MBPY pipeline/gen_props_fal.py CONCEPT.png OUT.glb
|
||||
|
||||
Prints `FAL_OK <glb> ~$0.16` on success (cost is the caller's to record), `FAL_ERR ...` on failure.
|
||||
"""
|
||||
import sys, os, subprocess, json, glob, shutil
|
||||
|
||||
MB = os.path.expanduser("~/Documents/MODELBEAST")
|
||||
sys.path.insert(0, MB)
|
||||
from server import db, settings as S # noqa: E402
|
||||
|
||||
ENDPOINT = "fal-ai/hunyuan3d/v2" # ~$0.16 geo (+ texture); see MESHGOD_BATCH.md pricing
|
||||
COST = 0.16
|
||||
|
||||
|
||||
def main():
|
||||
concept, out = sys.argv[1], sys.argv[2]
|
||||
con = db.connect()
|
||||
env = os.environ.copy()
|
||||
env.update(S.env_for_operator(S.get_all(con), ["FAL_KEY"]))
|
||||
if not env.get("FAL_KEY"):
|
||||
print("FAL_ERR FAL_KEY not configured in MODELBEAST settings"); sys.exit(2)
|
||||
|
||||
opdir = os.path.join(MB, "server/operators/fal_hunyuan3d")
|
||||
outdir = os.path.join(os.path.dirname(out) or ".", "_fal_" + os.path.basename(out).replace(".glb", ""))
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
params = json.dumps({"seed": 0, "textured_mesh": True})
|
||||
r = subprocess.run([sys.executable, os.path.join(opdir, "run.py"),
|
||||
"--input", concept, "--outdir", outdir, "--params", params],
|
||||
env=env, capture_output=True, text=True, timeout=600)
|
||||
glbs = glob.glob(os.path.join(outdir, "**", "*.glb"), recursive=True)
|
||||
if not glbs:
|
||||
print("FAL_ERR " + (r.stderr or r.stdout or "no glb")[-300:]); sys.exit(1)
|
||||
shutil.move(sorted(glbs)[0], out)
|
||||
shutil.rmtree(outdir, ignore_errors=True)
|
||||
print(f"FAL_OK {out} ~${COST:.2f} ({ENDPOINT})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,169 +0,0 @@
|
||||
"""Merge a skinned ped rig's sub-meshes to ONE draw: join meshes → pack all materials into a single
|
||||
atlas (bake) → one material → one primitive. Skinning (joints/weights) + skeleton are preserved, so
|
||||
the shared walk.glb/idle.glb clips still retarget. Round-7 E1 (gates D's full-density roster flip).
|
||||
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python merge_ped.py -- BATCH.json OUT_DIR
|
||||
|
||||
BATCH.json = list of {"src": "web/models/peds/x.glb", "name": "x", "atlas": 1024}.
|
||||
Approach = the bake_lowpoly EMIT trick without the remesh: keep the mesh + armature, give it a
|
||||
packed atlas UV, route each source material's base-colour→emission via the ORIGINAL UV, bake EMIT
|
||||
into one atlas image, assign a single material. Falls back to material-merge-only (prims = #mats)
|
||||
if a ped has no textures to bake.
|
||||
"""
|
||||
import bpy, sys, os, json
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
BATCH, OUT_DIR = ARGV[0], ARGV[1]
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def wipe():
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures):
|
||||
for d in list(coll):
|
||||
if d.users == 0:
|
||||
coll.remove(d)
|
||||
|
||||
|
||||
def import_join(src):
|
||||
bpy.ops.import_scene.gltf(filepath=src)
|
||||
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() # keeps vertex groups (weights) + armature modifier
|
||||
return bpy.context.view_layer.objects.active
|
||||
|
||||
|
||||
def route_basecolor_to_emission(obj, orig_uv):
|
||||
"""Each material: sample its base-colour texture via the ORIGINAL uv and feed Emission, so a
|
||||
bake on the atlas UV captures albedo. Add an explicit UVMap node so the atlas UV (active during
|
||||
bake) doesn't hijack the texture lookup."""
|
||||
for m in obj.data.materials:
|
||||
if not m or not m.use_nodes:
|
||||
continue
|
||||
nt = m.node_tree
|
||||
b = nt.nodes.get("Principled BSDF")
|
||||
if not b:
|
||||
continue
|
||||
uvn = nt.nodes.new("ShaderNodeUVMap"); uvn.uv_map = orig_uv
|
||||
for n in nt.nodes:
|
||||
if n.type == 'TEX_IMAGE':
|
||||
nt.links.new(uvn.outputs["UV"], n.inputs["Vector"])
|
||||
bc = b.inputs.get("Base Color")
|
||||
ec = b.inputs.get("Emission Color") or b.inputs.get("Emission")
|
||||
es = b.inputs.get("Emission Strength")
|
||||
if ec is None:
|
||||
continue
|
||||
if bc and bc.is_linked:
|
||||
nt.links.new(bc.links[0].from_socket, ec)
|
||||
elif bc:
|
||||
ec.default_value = bc.default_value
|
||||
if es is not None:
|
||||
es.default_value = 1.0
|
||||
|
||||
|
||||
def merge_one(spec):
|
||||
wipe()
|
||||
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
|
||||
obj = import_join(src)
|
||||
prim_before = "n/a"
|
||||
mats_before = len(obj.data.materials)
|
||||
has_tex = any(m and m.use_nodes and any(n.type == 'TEX_IMAGE' for n in m.node_tree.nodes)
|
||||
for m in obj.data.materials)
|
||||
size = spec.get("atlas", 1024)
|
||||
|
||||
if mats_before <= 1 or not has_tex:
|
||||
# already single-material (or untextured) — nothing to atlas; export as-is (1 primitive)
|
||||
out = _export(obj, spec)
|
||||
return {"name": spec["name"], "file": os.path.basename(out), "materials": mats_before,
|
||||
"atlased": False, "note": "already single-material" if mats_before <= 1 else "untextured"}
|
||||
|
||||
orig_uv = obj.data.uv_layers.active.name
|
||||
atlas_uv = obj.data.uv_layers.new(name="atlas").name
|
||||
obj.data.uv_layers.active = obj.data.uv_layers[atlas_uv]
|
||||
# pack every island of the atlas UV into non-overlapping 0-1 regions (separates the per-material
|
||||
# islands so their baked pixels don't collide)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.uv.select_all(action='SELECT')
|
||||
try:
|
||||
bpy.ops.uv.pack_islands(rotate=False, margin=0.003)
|
||||
except TypeError:
|
||||
bpy.ops.uv.pack_islands(margin=0.003)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
route_basecolor_to_emission(obj, orig_uv)
|
||||
|
||||
img = bpy.data.images.new("ped_atlas", size, size)
|
||||
for m in obj.data.materials:
|
||||
if m and m.use_nodes:
|
||||
tn = m.node_tree.nodes.new("ShaderNodeTexImage"); tn.image = img
|
||||
m.node_tree.nodes.active = tn # bake target per material
|
||||
|
||||
scn = bpy.context.scene
|
||||
scn.render.engine = 'CYCLES'
|
||||
try:
|
||||
scn.cycles.device = 'GPU'
|
||||
except Exception:
|
||||
pass
|
||||
scn.cycles.samples = 4
|
||||
scn.render.bake.margin = max(4, size // 128)
|
||||
scn.render.bake.use_selected_to_active = False
|
||||
obj.data.uv_layers.active = obj.data.uv_layers[atlas_uv]
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.bake(type='EMIT', use_clear=True)
|
||||
|
||||
# single atlas material, atlas UV only
|
||||
newmat = bpy.data.materials.new("ped_merged"); newmat.use_nodes = True
|
||||
nt = newmat.node_tree
|
||||
bsdf = nt.nodes.get("Principled BSDF")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(newmat)
|
||||
for uv in list(obj.data.uv_layers):
|
||||
if uv.name != atlas_uv:
|
||||
obj.data.uv_layers.remove(uv)
|
||||
|
||||
out = _export(obj, spec)
|
||||
return {"name": spec["name"], "file": os.path.basename(out), "materials_before": mats_before,
|
||||
"atlased": True, "atlas_px": size}
|
||||
|
||||
|
||||
def _export(obj, spec):
|
||||
out = os.path.join(OUT_DIR, os.path.basename(spec["src"]))
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
# select the armature too so the skin exports
|
||||
for o in bpy.data.objects:
|
||||
if o.type == 'ARMATURE':
|
||||
o.select_set(True)
|
||||
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=True,
|
||||
export_yup=True, export_skins=True, export_animations=False,
|
||||
export_image_format='WEBP', export_image_quality=90)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
specs = json.load(open(BATCH))
|
||||
results = []
|
||||
for spec in specs:
|
||||
try:
|
||||
r = merge_one(spec)
|
||||
results.append(r)
|
||||
print(f"OK {r['file']:32} atlased={r.get('atlased')} mats={r.get('materials_before', r.get('materials'))}")
|
||||
except Exception as e:
|
||||
print(f"ERR {spec.get('name')}: {e}")
|
||||
results.append({"name": spec.get("name"), "error": str(e)})
|
||||
json.dump({"results": results}, open(os.path.join(OUT_DIR, "_merge_results.json"), "w"), indent=2)
|
||||
print(f"MERGE_DONE ok={sum('error' not in r for r in results)}/{len(specs)}")
|
||||
|
||||
|
||||
main()
|
||||
@ -1,19 +1,5 @@
|
||||
{
|
||||
"_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.",
|
||||
"_round3_outcome": {
|
||||
"_comment": "Round 3 (Fable): these went through MODELBEAST (flux_local concept -> trellis_mac GLB) instead of fal.ai — free, on-device. Pipeline: pipeline/gen_props.py. Outcome below; see pipeline/gen_props.py + docs/shots/laneE/props_gen_local.png.",
|
||||
"generated_local": ["listening-booth", "drinks-fridge", "arcade-cabinet", "milkshake-mixer", "novelty-record"],
|
||||
"reused_from_depot": {"cash-register": "vintage-cash-register.glb (thriftgod_hero, already live on 3GOD)"},
|
||||
"to_primitive": {"magazine-rack": "TRELLIS wire rack = shell-soup, floors ~112k tris (COLLAPSE + meshopt both fail on wire) — the classic thin-wire gotcha; use an in-engine wire primitive instead"},
|
||||
"fal_fallback": {"_comment": "TRELLIS failed on these transparent-glass subjects (no GLB); sf3d gives a featureless blob. Local 2-attempt budget spent -> fal.ai, gated on John (<=$2.70). Both have in-engine primitive fallbacks so nothing blocks.", "glass-case": "transparent glass top defeats TRELLIS segmentation", "bus-shelter": "thin steel frame + perspex panels"},
|
||||
"note_tris": "TRELLIS meshes are dense multi-shell scans; normalize.py decimate floors 25-77k tris (>library <=5k law). Accepted for focal/optional/LOD-able props (texture-intact); a texture-bake-to-lowpoly / gltfpack pass is a follow-up."
|
||||
},
|
||||
"_round5_outcome": {
|
||||
"_comment": "Round 5 (Fable): fal.ai authorized ($22 balance). The R3 fal_fallback trio is now DONE + live on the depot. Pipeline: flux_local concept (frosted/opaque surfaces) -> pipeline/gen_props_fal.py (fal-ai/hunyuan3d/v2) -> normalize -> bake_lowpoly (<=8k, EMIT albedo bake). ALSO: all 6 R3/R4 TRELLIS hero props baked to <=8k (pipeline/bake_lowpoly.py). Costs logged in AUDIT.md R5.",
|
||||
"fal_done": {"glass-case": "1 attempt ~$0.16", "bus-shelter": "2 attempts ~$0.32 (att.2 opaque back wall)", "magazine-rack": "1 attempt ~$0.16 (optional, solid timber not wire)"},
|
||||
"fal_total": "~$0.64 of $22; guardrail held (nothing else on fal)",
|
||||
"baked_to_8k": ["drinks_fridge", "milkshake_mixer", "arcade_cabinet", "listening_booth", "novelty_record", "counter_till", "glass_case", "bus_shelter", "magazine_rack"]
|
||||
},
|
||||
"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"},
|
||||
|
||||
@ -183,23 +183,13 @@ def normalize_one(spec):
|
||||
for i in range(n0, n1):
|
||||
obj.data.polygons[i].material_index = widx
|
||||
|
||||
# decimate to budget (finish_glb.py:80). TRELLIS meshes are dense multi-shell scans where a
|
||||
# single COLLAPSE pass stalls far above target (arcade: 196k→25k at ratio 0.03); iterate —
|
||||
# each pass re-targets the budget from the now-smaller count — until within 10% or a pass
|
||||
# stops making progress (collapse limit). Library assets already under budget skip the loop.
|
||||
budget = spec.get("tris", 5000)
|
||||
# decimate to budget (finish_glb.py:80)
|
||||
tris = len(obj.data.polygons)
|
||||
passes = 0
|
||||
while tris > budget * 1.1 and passes < 5:
|
||||
budget = spec.get("tris", 5000)
|
||||
if tris > budget:
|
||||
m = obj.modifiers.new('decimate', 'DECIMATE')
|
||||
m.ratio = max(0.02, budget / tris)
|
||||
m.ratio = max(0.01, budget / tris)
|
||||
bpy.ops.object.modifier_apply(modifier=m.name)
|
||||
new = len(obj.data.polygons)
|
||||
passes += 1
|
||||
if new >= tris * 0.97: # barely moved — collapse limit reached, don't spin
|
||||
tris = new
|
||||
break
|
||||
tris = new
|
||||
tri_after = len(obj.data.polygons)
|
||||
|
||||
# self-illuminate dark bakes (finish_glb.py:88)
|
||||
|
||||
@ -60,26 +60,13 @@ def post(path, body, ctype):
|
||||
|
||||
|
||||
def main():
|
||||
if "--verify" in sys.argv: # drift-check the record vs the live depot, no publish
|
||||
recpath = os.path.join(ROOT, "pipeline", "_published.json")
|
||||
record = sorted(set(json.load(open(recpath)))) if os.path.exists(recpath) else []
|
||||
print(f"_published.json: {len(record)} entries")
|
||||
drift_check(record)
|
||||
return
|
||||
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
|
||||
# Passwordless tailnet path (round-3 decision #4): GOD3_DEPOT overridden to an allowlisted
|
||||
# tailnet endpoint (e.g. http://100.94.195.115:8788) trusts this box — no cookie needed.
|
||||
tailnet = DEPOT != "https://digalot.fyi/3god"
|
||||
glbs = sorted(g for g in glob.glob(NORM + "/*.glb")
|
||||
if os.path.basename(g) not in SKIP and (not only or only in os.path.basename(g)))
|
||||
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} matching {only!r} — run normalize.py first"); sys.exit(1)
|
||||
print(f"depot: {DEPOT} assets: {len(glbs)} {'(DRY RUN)' if DRY else ''}"
|
||||
+ (" [tailnet passwordless]" if tailnet else ""))
|
||||
if not DRY and not PW and not tailnet:
|
||||
print("ERROR: GOD3_PW not set and no tailnet GOD3_DEPOT. Either:\n"
|
||||
" export GOD3_DEPOT=http://100.94.195.115:8788 # passwordless from an allowlisted box\n"
|
||||
" or export GOD3_PW=... # public depot (ask John)")
|
||||
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:
|
||||
@ -109,49 +96,8 @@ def main():
|
||||
except Exception as e:
|
||||
print(f" ERR {name:38} {e}")
|
||||
if not DRY:
|
||||
# MERGE, never replace (decision #3): _published.json is the provenance record of everything
|
||||
# ever pushed — a --only run must not clobber it to just this run's files (the R5 bug).
|
||||
recpath = os.path.join(ROOT, "pipeline", "_published.json")
|
||||
existing = set()
|
||||
if os.path.exists(recpath):
|
||||
try:
|
||||
existing = set(json.load(open(recpath)))
|
||||
except Exception:
|
||||
pass
|
||||
merged = sorted(existing | set(published))
|
||||
json.dump(merged, open(recpath, "w"), indent=2)
|
||||
print(f"\npublished {len(published)}/{len(glbs)} this run → "
|
||||
f"_published.json now {len(merged)} (was {len(existing)}).")
|
||||
drift_check(merged)
|
||||
|
||||
|
||||
def drift_check(record):
|
||||
"""After publishing, compare _published.json against the live depot procity_* list; warn on
|
||||
drift (a published file missing from the record, or a recorded file absent from the depot)."""
|
||||
try:
|
||||
live = fetch_depot_procity()
|
||||
except Exception as e:
|
||||
print(f" (drift check skipped — depot list unreachable: {e})")
|
||||
return
|
||||
rec = set(record)
|
||||
missing_from_record = live - rec # live on depot but not recorded → record is stale
|
||||
missing_from_depot = rec - live # recorded but not live → deleted/never landed
|
||||
if missing_from_record:
|
||||
print(f" DRIFT: {len(missing_from_record)} on depot not in _published.json: "
|
||||
f"{sorted(missing_from_record)[:5]}")
|
||||
if missing_from_depot:
|
||||
print(f" DRIFT: {len(missing_from_depot)} in _published.json not live on depot: "
|
||||
f"{sorted(missing_from_depot)[:5]}")
|
||||
if not missing_from_record and not missing_from_depot:
|
||||
print(f" ✓ _published.json matches the live depot ({len(live)} procity assets).")
|
||||
|
||||
|
||||
def fetch_depot_procity():
|
||||
import urllib.request as _u
|
||||
with _u.urlopen(DEPOT + "/api/list", timeout=15) as r:
|
||||
d = json.loads(r.read().decode("utf-8"))
|
||||
assets = d.get("assets", d) if isinstance(d, dict) else d
|
||||
return {a["file"] for a in assets if str(a.get("file", "")).startswith("procity_")}
|
||||
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__":
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mirror the normalized GLBs into web/assets/models/ so loaders.js local-depot mode
|
||||
(?localdepot=1) can serve them from the dev server — Lanes B/C/F validate GLB loading with
|
||||
NO network and before/without the 3GOD publish.
|
||||
|
||||
python3 pipeline/stage_local_depot.py # copy pipeline/_normalized/*.glb -> web/assets/models/
|
||||
python3 pipeline/stage_local_depot.py --clean # wipe web/assets/models/ first
|
||||
|
||||
Re-run whenever _normalized changes (new hero props, re-normalized assets). The staged copies are
|
||||
the same files the depot serves as depot:<file>; local mode just points at these instead of the CDN.
|
||||
"""
|
||||
import os, sys, glob, shutil
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "pipeline", "_normalized")
|
||||
DST = os.path.join(ROOT, "web", "assets", "models")
|
||||
|
||||
if "--clean" in sys.argv and os.path.isdir(DST):
|
||||
shutil.rmtree(DST)
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
glbs = sorted(glob.glob(os.path.join(SRC, "*.glb")))
|
||||
if not glbs:
|
||||
print(f"no GLBs in {SRC} — run normalize.py first")
|
||||
sys.exit(1)
|
||||
|
||||
n = 0
|
||||
for src in glbs:
|
||||
dst = os.path.join(DST, os.path.basename(src))
|
||||
# copy only if missing or changed (idempotent, keeps mtimes stable for git)
|
||||
if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(src):
|
||||
shutil.copyfile(src, dst)
|
||||
n += 1
|
||||
print(f"staged {len(glbs)} GLBs -> web/assets/models/ ({n} new/changed)")
|
||||
print("loaders.js ?localdepot=1 now resolves depot:<file> to assets/models/<file>")
|
||||
@ -109,25 +109,11 @@ def main():
|
||||
|
||||
# fittings + furniture GLBs
|
||||
n_glb = 0
|
||||
manifest_files = set()
|
||||
for grp in ("fittings", "furniture"):
|
||||
for entry in m.get(grp, {}).values():
|
||||
check_glb(entry, depot)
|
||||
manifest_files.add(entry["file"])
|
||||
n_glb += 1
|
||||
|
||||
# provenance-drift gate: every manifest depot GLB must be recorded in _published.json, so a
|
||||
# clobbered/stale provenance record (the R5 bug) fails QA loudly instead of hiding.
|
||||
recpath = os.path.join(ROOT, "pipeline", "_published.json")
|
||||
try:
|
||||
record = set(json.load(open(recpath)))
|
||||
except Exception as e:
|
||||
err(f"_published.json unreadable: {e}")
|
||||
record = set()
|
||||
for f in sorted(manifest_files):
|
||||
if f.startswith("procity_") and f not in record:
|
||||
err(f"manifest GLB not in _published.json (provenance drift): {f}")
|
||||
|
||||
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}")
|
||||
|
||||
@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — v2 flag enforcement harness (round-6 F1).
|
||||
|
||||
The machine version of what the R5 review did by hand. Three things, headless, <2 min:
|
||||
1. FLAGS-OFF REGRESSION — the enforcement arm of the prime law. Boot with no flags → the plan
|
||||
fingerprint must equal the golden 0x3fa36874, the settled street view must stay within the v1
|
||||
budget (≤300 draws / ≤200k tris) AND within tolerance of the v1.1 snapshot, every v2 subsystem
|
||||
must report inactive, and there must be ZERO console errors. A v2 flag leaking into the default
|
||||
boot trips this.
|
||||
2. PER-FLAG SMOKE — for each landed flag (winmap, dig, roster, +plansrc when A lands): boot flag-on,
|
||||
cross ≥2 chunks, enter one interior (dig: also open+close a riffle), 0 console errors.
|
||||
3. ALL-ON COMBO (flag-composition law, decision #4): every landed flag on at once, same smoke.
|
||||
|
||||
Run: cd web && python3 -m http.server 8130 (or let this script spawn its own)
|
||||
tools/.venv/bin/python tools/flags_check.py
|
||||
Wired into tools/qa.sh as a WARN-level gate this round (strict in round 7). Exit 0 = all pass;
|
||||
non-zero = a check failed (qa.sh reports it as a warning this round).
|
||||
"""
|
||||
import sys, json, time, socket, subprocess, pathlib
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit("playwright not installed — python3 -m venv tools/.venv && "
|
||||
"tools/.venv/bin/pip install playwright && tools/.venv/bin/python -m playwright install chromium")
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
PORT = 8130
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
GOLDEN_HASH = 0x3fa36874 # frozen determinism anchor (Lane A selfcheck) — UNCHANGED by the R7 flip
|
||||
# flags-off DBG.shot('street_noon') snapshot. RE-PINNED at the R7 roster flip (prime-law amendment):
|
||||
# old (v1.1, roster-off): 163 draws, 19000 tris
|
||||
# new (R7, roster default-on): 164 draws, ~66000 tris
|
||||
# Draws barely move — E1's ped sub-mesh merge makes each near-rig ~1 draw, which is exactly what let
|
||||
# the roster flip to full density. Tris jump is the streamed crowd (each near-rig ~2.4k tris); it is
|
||||
# inherently variable frame-to-frame (how many rigs are near at sample time), so the drift WARN below
|
||||
# is DRAWS-ONLY — tris is guarded strictly by the STREET_TRIS_MAX hard cap, not a soft snapshot.
|
||||
BASE_DRAWS, BASE_TRIS = 164, 66000
|
||||
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
|
||||
KNOWN_FLAGS = ['winmap=1', 'dig=1', 'roster=stream'] # plansrc=osm auto-appended if Lane A landed it
|
||||
|
||||
fails, warns = [], []
|
||||
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
||||
def WARN(m): warns.append(m); print(f" \033[33m! WARN\033[0m {m}")
|
||||
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
||||
def head(m): print(f"\n\033[1m{m}\033[0m")
|
||||
|
||||
def port_up(port):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.4)
|
||||
return s.connect_ex(('127.0.0.1', port)) == 0
|
||||
|
||||
def ensure_server():
|
||||
if port_up(PORT):
|
||||
return None # reuse a running dev server
|
||||
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)],
|
||||
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(50):
|
||||
if port_up(PORT): return proc
|
||||
time.sleep(0.1)
|
||||
proc.terminate(); sys.exit(f"could not start http.server on :{PORT}")
|
||||
|
||||
# ── page helpers ─────────────────────────────────────────────────────────────
|
||||
def new_page(p):
|
||||
b = p.chromium.launch()
|
||||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||||
errs = []
|
||||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||
return b, pg, errs
|
||||
|
||||
def boot(pg, query):
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
|
||||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
||||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||
try:
|
||||
pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)", timeout=12000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def plansrc_landed(p):
|
||||
"""Boot ?plansrc=osm; landed iff the shell exposes an 'osm' plan source (else the flag is ignored)."""
|
||||
b, pg, _ = new_page(p)
|
||||
try:
|
||||
boot(pg, 'plansrc=osm')
|
||||
src = pg.evaluate("() => (window.PROCITY.plan && (window.PROCITY.plan.source||window.PROCITY.plan.planSource)) "
|
||||
"|| window.PROCITY.planSource || null")
|
||||
return src == 'osm'
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ── the three checks ─────────────────────────────────────────────────────────
|
||||
def flags_off_regression(p):
|
||||
head('FLAGS-OFF REGRESSION (prime-law enforcement)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, '')
|
||||
state = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, D=window.DBG;
|
||||
D.shot('street_noon'); // fixed pose → comparable draws/tris
|
||||
const i = D.info();
|
||||
const c = P.citizens || {};
|
||||
return { draws:i.drawCalls, tris:i.tris, mode:i.mode,
|
||||
winmap: !!(P.winmap && P.winmap.active),
|
||||
digActive: !!(P.interiorMode && P.interiorMode.digActive),
|
||||
streamMode: !!c.streamMode }; // R7: real roster-mode flag (was reading a nonexistent prop)
|
||||
}""")
|
||||
# determinism anchor — run Lane A's selfcheck in-process (golden fingerprint)
|
||||
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT),
|
||||
capture_output=True, text=True)
|
||||
hash_ok = (f'{GOLDEN_HASH:#010x}'.replace('0x', '0x') in r.stdout) or (hex(GOLDEN_HASH) in r.stdout) \
|
||||
or ('3fa36874' in r.stdout)
|
||||
if hash_ok and r.returncode == 0: OK(f'plan fingerprint == golden {hex(GOLDEN_HASH)} (selfcheck green)')
|
||||
else: FAIL(f'plan fingerprint != golden {hex(GOLDEN_HASH)} — determinism/prime-law broken')
|
||||
|
||||
# winmap + dig stay OPT-IN (off by default). The streamed roster is now the shipping DEFAULT
|
||||
# (R7 flip / prime-law amendment) — so it must be ON, not off, on a no-flag boot.
|
||||
if state['winmap'] or state['digActive']:
|
||||
FAIL(f"an opt-in v2 flag is active on default boot (winmap/dig): {state}")
|
||||
else: OK('opt-in flags off on default boot (winmap/dig)')
|
||||
if state['streamMode']:
|
||||
OK('R7 roster flip in effect — streamed roster ACTIVE on default boot')
|
||||
else: FAIL('roster flip REGRESSED — default boot is not streaming (expected streamMode:true)')
|
||||
|
||||
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
||||
OK(f"flags-off street view within budget (draws {state['draws']}≤{STREET_DRAWS_MAX}, tris {state['tris']}≤{STREET_TRIS_MAX})")
|
||||
else: FAIL(f"flags-off street view OVER budget: draws {state['draws']}, tris {state['tris']}")
|
||||
|
||||
# Drift WARN is DRAWS-ONLY — tris is crowd-variable post-flip (guarded by the hard cap above).
|
||||
dd = abs(state['draws'] - BASE_DRAWS) / BASE_DRAWS
|
||||
if dd <= 0.40:
|
||||
OK(f"draws within 40% of re-pinned baseline ({state['draws']} vs {BASE_DRAWS}); tris {state['tris']} (crowd-variable, ≤cap)")
|
||||
else:
|
||||
WARN(f"draws drifted >40% from baseline ({state['draws']} vs {BASE_DRAWS}) — investigate")
|
||||
|
||||
if errs: FAIL(f"{len(errs)} console error(s) on default boot; first: {errs[0][:140]}")
|
||||
else: OK('0 console errors on default boot')
|
||||
|
||||
# R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors.
|
||||
b2, pg2, errs2 = new_page(p)
|
||||
try:
|
||||
boot(pg2, 'roster=v1')
|
||||
esc = pg2.evaluate("() => ({ streamMode: !!(window.PROCITY.citizens||{}).streamMode, mode: window.DBG.info().mode })")
|
||||
if not esc['streamMode'] and esc['mode'] == 'street':
|
||||
OK('escape hatch ?roster=v1 → fixed roster (streamMode:false), renders')
|
||||
else: FAIL(f"escape hatch ?roster=v1 broken: {esc}")
|
||||
if errs2: FAIL(f"?roster=v1: {len(errs2)} console error(s); first: {errs2[0][:140]}")
|
||||
finally:
|
||||
b2.close()
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def smoke(p, label, query, is_combo=False):
|
||||
head(f'SMOKE: {label} (?{query})')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, query)
|
||||
# cross ≥2 chunks by teleporting between separated street nodes
|
||||
chunks = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, D=window.DBG, seen=new Set();
|
||||
const ns = P.plan.streets.nodes;
|
||||
for (const idx of [0, (ns.length/3|0), (2*ns.length/3|0), ns.length-1]) {
|
||||
const n = ns[idx]; D.teleport(n.x, n.z, 0);
|
||||
for (let k=0;k<10;k++) (P.citizens&&P.citizens.update&&P.citizens.update(0.05));
|
||||
const c = D.info().chunk; if (c!=null) seen.add(String(c));
|
||||
}
|
||||
return seen.size;
|
||||
}""")
|
||||
if chunks >= 2: OK(f'crossed {chunks} chunks streaming (flag active, no crash)')
|
||||
else: WARN(f'only {chunks} distinct chunks seen (teleport/stream probe)')
|
||||
|
||||
# enter one interior (open shop), exit
|
||||
entered = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, D=window.DBG;
|
||||
D.setSegment(2);
|
||||
const s=(P.plan.shops||[]).find(x=>x.type==='record' && P.isOpen(x)) || (P.plan.shops||[]).find(x=>P.isOpen(x));
|
||||
if(!s) return {ok:false};
|
||||
D.enterShop(s.id);
|
||||
const inMode = D.info().mode==='interior';
|
||||
return {ok:inMode, shop:s.name};
|
||||
}""")
|
||||
if entered.get('ok'): OK(f"entered interior '{entered.get('shop')}' (mode=interior)")
|
||||
else: FAIL('could not enter an interior')
|
||||
|
||||
# dig-specific: open one riffle + close, leak-free
|
||||
if 'dig=1' in query:
|
||||
dug = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, THREE=P.THREE, room=P.interiorMode.current;
|
||||
if(!room) return {ok:false, why:'no room'};
|
||||
const bins=[]; room.group.traverse(o=>{ if(o.userData&&o.userData.kind==='bin') bins.push(o); });
|
||||
if(!bins.length) return {ok:false, why:'no bins'};
|
||||
const bp=new THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||||
P.camera.position.set(bp.x,1.6,bp.z+1.8); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
|
||||
window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}));
|
||||
const opened = P.interiorMode.digActive;
|
||||
const btn=document.querySelector('.pcdg-out'); if(btn) btn.click();
|
||||
return { ok: opened, closed: !P.interiorMode.digActive };
|
||||
}""")
|
||||
pg.wait_for_timeout(300)
|
||||
if dug.get('ok') and dug.get('closed'): OK('dig: riffle opened (E on bin) + closed (WALK OUT)')
|
||||
elif dug.get('ok'): WARN(f"dig: opened but close not confirmed ({dug})")
|
||||
else: FAIL(f"dig: riffle did not open ({dug.get('why','?')})")
|
||||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||||
|
||||
if errs: FAIL(f"{label}: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
else: OK(f'{label}: 0 console errors')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def interior_budget(p):
|
||||
"""Decision #2: interior draw budget is law — ≤350 draws worst room. Sweep every shop type
|
||||
(dig on, so this also confirms batching kept ?dig=1 riffling). C enforces via its sweep; F pins it."""
|
||||
head('INTERIOR DRAW BUDGET (≤350 draws/room law — decision #2)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'dig=1')
|
||||
pg.evaluate("() => window.DBG.setSegment(2)") # midday → shops open
|
||||
types = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall']
|
||||
rows, seen = [], set()
|
||||
for t in types:
|
||||
picked = pg.evaluate("""(t) => {
|
||||
const P=window.PROCITY, D=window.DBG;
|
||||
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
|
||||
if(!s) return null; D.enterShop(s.id); return {id:s.id, name:s.name};
|
||||
}""", t)
|
||||
if not picked:
|
||||
continue
|
||||
pg.wait_for_timeout(500) # let async GLB fittings load → settled worst-case draws
|
||||
info = pg.evaluate("() => { const i=window.DBG.info(); return {draws:i.drawCalls, tris:i.tris, mode:i.mode}; }")
|
||||
rows.append({'type': t, 'shop': picked['name'], **info})
|
||||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(150)
|
||||
over = [r for r in rows if r['draws'] > 350]
|
||||
for r in over:
|
||||
FAIL(f"interior {r['type']} '{r['shop']}': {r['draws']} draws > 350")
|
||||
if rows and not over:
|
||||
worst = max(rows, key=lambda r: r['draws'])
|
||||
OK(f"all {len(rows)} room types ≤350 draws (worst: {worst['type']} {worst['draws']}, tris {worst['tris']})")
|
||||
elif not rows:
|
||||
WARN('no interiors sampled (no open shops of the swept types)')
|
||||
if errs:
|
||||
FAIL(f"interior sweep: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def smoke_stock(p):
|
||||
"""NEW FLAG (warn-level, decision #4): ?stock=real → real GODVERSE sleeves in the crates.
|
||||
Assert non-parody: real sleeves are batched atlas meshes tagged userData.isStock (Lane C recipe)."""
|
||||
head('SMOKE: stock=real (?stock=real — real sleeves; new flag → warn-level)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'stock=real&dig=1')
|
||||
ready = False
|
||||
try: # the shell preloads the pack async — wait for getStockPack('record') to resolve
|
||||
pg.wait_for_function(
|
||||
"async () => { const m = await import('./js/interiors/interiors.js');"
|
||||
" return !!(m.getStockPack && m.getStockPack('record')); }", timeout=12000)
|
||||
ready = True
|
||||
except Exception:
|
||||
pass
|
||||
res = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||||
const rec=(P.plan.shops||[]).find(s=>s.type==='record' && P.isOpen(s));
|
||||
if(!rec) return {err:'no open record shop'};
|
||||
D.enterShop(rec.id);
|
||||
let n=0; P.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; });
|
||||
return { shop: rec.name, isStock: n };
|
||||
}""")
|
||||
if res.get('err'): WARN(f"stock=real: {res['err']}")
|
||||
elif not ready: WARN(f"stock=real: pack didn't preload in 12s — parody fallback (fail-soft); {res['isStock']} isStock meshes")
|
||||
elif res['isStock'] > 0: OK(f"stock=real: {res['isStock']} real atlas sleeve mesh(es) in '{res['shop']}' — non-parody, batched")
|
||||
else: WARN(f"stock=real: pack loaded but 0 isStock atlas meshes in '{res['shop']}'")
|
||||
if errs: WARN(f"stock=real: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def smoke_weather(p):
|
||||
"""NEW FLAG (warn-level): ?weather=1 (Lane B). Auto-skips if not landed yet."""
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'weather=1')
|
||||
w = pg.evaluate("() => { const P=window.PROCITY||{}; const w=P.weather||(P.lighting&&P.lighting.weather); "
|
||||
"return w ? { active:!!w.active, particles:!!(w.particles||w.rain), wet:!!w.wet } : null; }")
|
||||
if not w:
|
||||
print("· weather=1 not landed (no weather subsystem) — skipping (Lane B)")
|
||||
return
|
||||
head('SMOKE: weather=1 (new flag → warn-level)')
|
||||
if w['active'] and w['particles']: OK(f"weather=1 active with particle layer: {w}")
|
||||
else: WARN(f"weather=1 present but incomplete: {w}")
|
||||
if errs: WARN(f"weather=1: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def main():
|
||||
srv = ensure_server()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
flags = list(KNOWN_FLAGS)
|
||||
if plansrc_landed(p):
|
||||
flags.append('plansrc=osm'); print("· plansrc=osm detected as landed — included")
|
||||
else:
|
||||
print("· plansrc=osm not landed (Lane A) — skipping that flag")
|
||||
|
||||
flags_off_regression(p)
|
||||
interior_budget(p)
|
||||
for fl in flags:
|
||||
smoke(p, fl.split('=')[0], fl)
|
||||
smoke(p, 'ALL-ON combo', '&'.join(flags), is_combo=True)
|
||||
smoke_stock(p) # new flag (warn-level) — ?stock=real
|
||||
smoke_weather(p) # new flag (warn-level) — ?weather=1 (auto-skips if not landed)
|
||||
finally:
|
||||
if srv: srv.terminate()
|
||||
|
||||
head('VERDICT')
|
||||
print(json.dumps({'fails': len(fails), 'warns': len(warns)}, indent=0))
|
||||
if fails:
|
||||
print(f"\033[31m● flags_check RED\033[0m — {len(fails)} failure(s), {len(warns)} warning(s).")
|
||||
for f in fails: print(' FAIL:', f)
|
||||
sys.exit(1)
|
||||
print(f"\033[32m● flags_check GREEN\033[0m — {len(warns)} warning(s).")
|
||||
for w in warns: print(' warn:', w)
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
38
tools/qa.sh
@ -37,17 +37,6 @@ skip_gate() { # skip_gate "name" "reason"
|
||||
printf '%s ⊘ SKIP%s %s\n\n' "$c_yel" "$c_off" "$2"
|
||||
skip=$((skip+1))
|
||||
}
|
||||
warnn=0
|
||||
soft_skip() { # non-blocking skip for a warn-level gate — does NOT trip --strict readiness
|
||||
printf '%s▶ %s%s\n' "$c_bold" "$1" "$c_off"
|
||||
printf '%s ⊘ SKIP%s %s (warn-level, non-blocking)\n\n' "$c_yel" "$c_off" "$2"
|
||||
}
|
||||
warn_gate() { # warn_gate "name" cmd... — runs, but a failure is a WARN not a FAIL (round-6 policy)
|
||||
local name="$1"; shift
|
||||
printf '%s▶ %s%s (warn-level)\n' "$c_bold" "$name" "$c_off"
|
||||
if "$@"; then printf '%s ✓ PASS%s %s\n\n' "$c_grn" "$c_off" "$name"; pass=$((pass+1));
|
||||
else printf '%s ! WARN%s %s (non-blocking this round — strict in r7)\n\n' "$c_yel" "$c_off" "$name"; warnn=$((warnn+1)); fi
|
||||
}
|
||||
|
||||
printf '%sPROCITY QA GATES%s (%s)\n' "$c_bold" "$c_off" "$([ $STRICT = 1 ] && echo strict || echo lenient)"
|
||||
hr
|
||||
@ -70,36 +59,17 @@ else
|
||||
fi
|
||||
|
||||
# ── Gate 3: Lane E manifest validator ────────────────────────────────────────
|
||||
# [Lane F R3] --depot: the 16 GLBs are published + live on the 3GOD depot (Fable, 2026-07-14), so the
|
||||
# gate now REQUIRES them reachable (was a soft local-only check). Override the endpoint with GOD3_DEPOT
|
||||
# (tailnet path) if digalot.fyi is unreachable; the validator falls back to the manifest's depot field.
|
||||
if [ -f pipeline/validate_manifest.py ]; then
|
||||
run_gate "manifest validator (files exist · footprints sane · GLBs live on depot · JSON parses)" \
|
||||
python3 pipeline/validate_manifest.py --depot
|
||||
run_gate "manifest validator (files exist · footprints sane · JSON parses)" \
|
||||
python3 pipeline/validate_manifest.py
|
||||
else
|
||||
skip_gate "manifest validator" "pipeline/validate_manifest.py not landed yet (Lane E)"
|
||||
fi
|
||||
|
||||
# ── Gate 4 (warn-level, round 6): v2 flag enforcement harness ────────────────
|
||||
# Flags-off regression (prime-law) + per-flag + all-on-combo smokes. Needs the Playwright venv +
|
||||
# a browser (self-contained server), auto-skips where absent. STRICT as of R7 (F2): a harness FAIL
|
||||
# now fails qa (flags-off regression + the four existing flags + all-on combo). New flags (stock,
|
||||
# weather) report at warn inside the harness, so they never trip this gate until they graduate.
|
||||
# Skip explicitly with --no-flags (fast determinism-only runs).
|
||||
FLAGS_SKIP=0; for a in "$@"; do [ "$a" = "--no-flags" ] && FLAGS_SKIP=1; done
|
||||
if [ "$FLAGS_SKIP" = 1 ]; then
|
||||
soft_skip "v2 flags harness" "--no-flags"
|
||||
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
run_gate "v2 flags harness (flags-off regression · per-flag · all-on combo · STRICT r7)" \
|
||||
tools/.venv/bin/python tools/flags_check.py
|
||||
else
|
||||
soft_skip "v2 flags harness" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
hr
|
||||
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \
|
||||
"$c_bold" "$c_off" "$c_grn" "$pass" "$c_off" "$c_red" "$failn" "$c_off" "$c_yel" "$warnn" "$c_off" "$c_yel" "$skip" "$c_off"
|
||||
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d skipped%s\n' \
|
||||
"$c_bold" "$c_off" "$c_grn" "$pass" "$c_off" "$c_red" "$failn" "$c_off" "$c_yel" "$skip" "$c_off"
|
||||
|
||||
if [ "$failn" -gt 0 ]; then
|
||||
printf '%s● QA RED%s — fix the failing gate(s) above.\n' "$c_red" "$c_off"; exit 1
|
||||
|
||||
@ -83,21 +83,15 @@ for (const seed of seeds) {
|
||||
if (h[0] < 0 || h[0] > 24 || h[1] < 0 || h[1] > 24) fail(`shop ${s.id}: hours ${JSON.stringify(h)} out of range`);
|
||||
if (h[1] >= 22) lateCount++; // Lane A encodes hours as simple 24h ints (open<close, no wrap)
|
||||
}
|
||||
// storeys must sit inside the type's registry [min,max], EXCEPT tall-capable types may reach a
|
||||
// 3-storey corner anchor (CITY_SPEC). Lane A's rule (LANE_A_NOTES R3, verified across 8 seeds):
|
||||
// permittedMax = registryMax>=2 ? min(registryMax+1, 3) : registryMax. Anything outside
|
||||
// [registryMin, permittedMax] is REAL drift — a single-storey type at >1 still trips.
|
||||
// storeys must sit inside the type's registry [min,max] — unless it's a taller corner anchor.
|
||||
const sr = def.storeys;
|
||||
if (Array.isArray(sr) && sr.length === 2) {
|
||||
const permittedMax = sr[1] >= 2 ? Math.min(sr[1] + 1, 3) : sr[1];
|
||||
if (s.storeys < sr[0] || s.storeys > permittedMax) storeysOff++;
|
||||
}
|
||||
if (Array.isArray(sr) && sr.length === 2 && (s.storeys < sr[0] || s.storeys > sr[1])) storeysOff++;
|
||||
if (/['’]s['’]s\b/.test(s.name || '')) dblApostrophe++;
|
||||
}
|
||||
if (skinOffPool) warn(`${skinOffPool} shop(s) use a facadeSkin outside their registry type pool `
|
||||
+ `(ok if Lane A widened pools intentionally; else a plan↔registry drift)`);
|
||||
if (storeysOff) warn(`${storeysOff} shop(s) have storeys outside [registryMin, permittedMax] `
|
||||
+ `(corner-anchor 3-storey is allowed for tall types; this is REAL plan↔registry drift — Lane A)`);
|
||||
if (storeysOff) warn(`${storeysOff} shop(s) have storeys outside their registry type range `
|
||||
+ `(ok if these are the CITY_SPEC "occasional 3-storey corner anchor"; else a plan↔registry drift — ask Lane A)`);
|
||||
if (dblApostrophe) warn(`${dblApostrophe} shop name(s) have a doubled possessive ("X's's") — Lane A names.js`);
|
||||
if (lateCount === 0) warn(`no open-late shop (close ≥22:00) — LANE_F §3.5 wants at least one`);
|
||||
else ok(`${lateCount} open-late shop(s) (close ≥22:00) — §3.5 satisfied`);
|
||||
|
||||
@ -213,26 +213,6 @@ if (existsSync(R('web/js/citygen/plan.js'))) {
|
||||
else fail('chunkIndex(plan) not exported from plan.js');
|
||||
info(`plan(1234): ${p1?.shops?.length ?? '?'} shops, ${p1?.lots?.length ?? '?'} lots, ` +
|
||||
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
|
||||
|
||||
// [F2] plan-source selector (?plansrc=osm) — pin BOTH goldens keyed by (seed, plansrc).
|
||||
// Lane A contract (LANE_A_NOTES F2): synthetic 0x3fa36874 (selfcheck), osm 0x34cfdec0.
|
||||
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
|
||||
try {
|
||||
const bar = await import(R('web/js/citygen/index.js'));
|
||||
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
|
||||
const { xmur3 } = await import(R('web/js/core/prng.js'));
|
||||
const oa = bar.generatePlanOSM(20261990), ob = bar.generatePlanOSM(20261990);
|
||||
assert(JSON.stringify(oa) === JSON.stringify(ob),
|
||||
'generatePlanOSM(20261990) is deterministic across two runs', 'OSM plan NON-DETERMINISTIC');
|
||||
const oh = (xmur3(JSON.stringify(oa))() >>> 0).toString(16);
|
||||
assert(`0x${oh}` === '0x34cfdec0',
|
||||
`osm plan fingerprint == golden 0x34cfdec0 ("${oa.name}", ${oa.shops?.length} shops)`,
|
||||
`osm plan fingerprint DRIFTED: 0x${oh} (expect 0x34cfdec0)`);
|
||||
assert(bar.generatePlanFor(20261990, 'osm').source === 'osm'
|
||||
&& (bar.generatePlanFor(20261990, 'synthetic').source || 'synthetic') === 'synthetic',
|
||||
'generatePlanFor routes synthetic|osm correctly', 'generatePlanFor selector mis-routes');
|
||||
}
|
||||
} catch (e) { fail(`osm plan-source check threw: ${e.message}`); }
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`importing/running plan.js threw: ${e.message}`);
|
||||
|
||||
@ -71,13 +71,7 @@ def main():
|
||||
errors = []
|
||||
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
|
||||
pg.goto(url)
|
||||
# DBG installs asynchronously (main() → dynamic import of dbg.js), so POLL for it — reading
|
||||
# window.DBG the instant goto's load event fires races the async boot and false-reports absent.
|
||||
try:
|
||||
pg.wait_for_function("() => !!(window.DBG)", timeout=15000)
|
||||
has_dbg = True
|
||||
except Exception:
|
||||
has_dbg = False
|
||||
has_dbg = pg.evaluate("() => !!(window.DBG)")
|
||||
if not has_dbg:
|
||||
print("⚠ window.DBG absent (Lane B shell not landed or ?dbg hook missing) — "
|
||||
"capturing a single default frame only.")
|
||||
@ -88,42 +82,17 @@ def main():
|
||||
return
|
||||
|
||||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
||||
# dismiss the start splash (#pc-start, z-index 40) — DBG.shot() renders the canvas beneath it,
|
||||
# but a headless capture never clicks "walk into town", so the overlay would cover every frame.
|
||||
pg.evaluate("() => { const o = document.getElementById('pc-start'); if (o) o.style.display = 'none'; }")
|
||||
# best-effort: let the rig fleet finish so peds/keepers show as GLB characters, not placeholders
|
||||
try:
|
||||
pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)",
|
||||
timeout=12000)
|
||||
except Exception:
|
||||
pass
|
||||
# A REAL viewport toggle (not a synthetic resize event) forces the EffectComposer to rebuild its
|
||||
# render targets at the true size — headless can construct the composer before the viewport settles,
|
||||
# which otherwise letterboxes every frame into a square sub-region.
|
||||
pg.set_viewport_size({'width': 1281, 'height': 721}); pg.wait_for_timeout(120)
|
||||
pg.set_viewport_size({'width': 1280, 'height': 720}); pg.wait_for_timeout(250)
|
||||
for name in TOUR:
|
||||
enter = ENTER.get(name)
|
||||
if enter:
|
||||
# Interior shot: DBG.shot() forces street mode + street bookmarks, so it can't frame an
|
||||
# interior. Instead resolve an OPEN shop of the wanted type, enterShop(id) (which switches
|
||||
# to interior mode + poses the camera at the room spawn), and capture that view directly.
|
||||
pg.evaluate("""(t) => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
D.setSegment(2); // midday so the shop is open
|
||||
const shops = P.plan.shops || [];
|
||||
const shop = shops.find(s => s.type === t && (!P.isOpen || P.isOpen(s)))
|
||||
|| shops.find(s => s.type === t);
|
||||
if (shop) D.enterShop(shop.id);
|
||||
}""", enter['type'])
|
||||
pg.wait_for_timeout(900) # interior build + keeper/hero-prop GLBs settle
|
||||
pg.screenshot(path=str(out / f'{name}.png'))
|
||||
pg.evaluate("(o) => window.DBG.enterShop(o)", enter)
|
||||
pg.wait_for_timeout(600)
|
||||
pg.evaluate("(n) => window.DBG.shot(n)", name)
|
||||
pg.wait_for_timeout(900) # settle canvas textures, lights, async GLB swaps
|
||||
pg.screenshot(path=str(out / f'{name}.png'))
|
||||
if enter:
|
||||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(300)
|
||||
else:
|
||||
pg.evaluate("(n) => window.DBG.shot(n)", name)
|
||||
pg.wait_for_timeout(900) # settle canvas textures, lights, async GLB swaps
|
||||
pg.screenshot(path=str(out / f'{name}.png'))
|
||||
print('shot', name)
|
||||
b.close()
|
||||
|
||||
|
||||
@ -56,48 +56,21 @@ def main():
|
||||
pg.on('pageerror', lambda e: cerr.append(str(e)))
|
||||
pg.goto(url)
|
||||
|
||||
# DBG installs asynchronously (main() → dynamic import of dbg.js) — poll rather than read the
|
||||
# instant goto's load event fires (that races the async boot and false-reports absent).
|
||||
try:
|
||||
pg.wait_for_function("() => !!window.DBG", timeout=15000)
|
||||
except Exception:
|
||||
sys.exit("⚠ window.DBG absent after 15s — boot the game with ?dbg=1 (soak.py adds it). "
|
||||
"(Gate is defined and ready.)")
|
||||
if not pg.evaluate("() => !!window.DBG"):
|
||||
sys.exit("⚠ window.DBG absent — Lane B shell/hook not landed; soak cannot run yet. "
|
||||
"(This is expected pre-Lane-B; the gate is defined and ready.)")
|
||||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
||||
# Pin midday (setSegment pauses the day clock). §3.5 locks closed shops, so without this a long
|
||||
# soak drifts into night where only the openLate shop opens — starving the ≥MIN_SHOPS entry gate.
|
||||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||
|
||||
# The CityPlan drives the walk. DBG doesn't expose it; the shell's window.PROCITY does.
|
||||
plan = pg.evaluate("() => (window.PROCITY && window.PROCITY.plan) || window.DBG.plan || null")
|
||||
plan = pg.evaluate("() => window.DBG.plan || null")
|
||||
if not plan:
|
||||
sys.exit("⚠ plan not exposed on window.PROCITY/DBG — need the active CityPlan to script a walk.")
|
||||
sys.exit("⚠ DBG.plan not exposed — need the active CityPlan to script a walk.")
|
||||
shops = plan.get('shops', [])
|
||||
lots = {l['id']: l for l in plan.get('lots', [])} # shop.lot is a lot ID, not a lot object
|
||||
base = pg.evaluate("() => { const i = window.DBG.info(); return {g:i.geometries, t:i.textures}; }")
|
||||
|
||||
# Waypoint path: sample shop lots as targets so we cross many chunks and pass many doors.
|
||||
# (Duration ≈ len(targets) × per-step waits; a strict --minutes loop is a TODO to tune
|
||||
# against Lane B's real DBG API — the chunk/shop minimums are what actually gate here.)
|
||||
targets = shops[:: max(1, len(shops) // 60)] if shops else [] # denser sampling → cross ≥MIN_CHUNKS distinct chunks
|
||||
|
||||
# Pre-warm the shared GLB cache before the leak-checked walk. The FIRST render of each fitting
|
||||
# GLB (and the rig keeper) uploads its persistent, SHARED geometry once — reused by every later
|
||||
# room. That one-time upload would otherwise read as a per-interior "leak"; warming here (one
|
||||
# shop per type, waiting for the depot GLB to load+render) makes the walk's leak check honest.
|
||||
# No-op under ?noassets (no GLBs load).
|
||||
if not noassets:
|
||||
warmed = set()
|
||||
for shop in shops:
|
||||
t = shop.get('type')
|
||||
if t in warmed:
|
||||
continue
|
||||
warmed.add(t)
|
||||
pg.evaluate("(id) => window.DBG.enterShop(id)", shop.get('id'))
|
||||
if pg.evaluate("() => window.DBG.info().mode === 'interior'"):
|
||||
pg.wait_for_timeout(2500) # depot fitting GLB load + first render → GPU upload → cache
|
||||
pg.evaluate("() => window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(300)
|
||||
targets = shops[:: max(1, len(shops) // 40)] if shops else []
|
||||
|
||||
def sample():
|
||||
nonlocal peak_draws, peak_tris
|
||||
@ -116,8 +89,8 @@ def main():
|
||||
circuit = 0
|
||||
while True:
|
||||
for idx, shop in enumerate(targets):
|
||||
lot = lots.get(shop.get('lot')) # resolve the lot ID → its world position
|
||||
x, z = (lot['x'], lot['z']) if lot else (0, 0)
|
||||
lot = shop.get('lot') or shop
|
||||
x, z = lot.get('x', 0), lot.get('z', 0)
|
||||
pg.evaluate("(p) => window.DBG.teleport(p.x, p.z, 0)", {'x': x, 'z': z})
|
||||
pg.wait_for_timeout(1000)
|
||||
sample()
|
||||
@ -127,7 +100,7 @@ def main():
|
||||
# ≥MIN_SHOPS gate, which is the correct way to fail it.
|
||||
if idx % 2 == 0 and shops_entered < MIN_SHOPS + 5:
|
||||
before = pg.evaluate(MEM)
|
||||
pg.evaluate("(id) => window.DBG.enterShop(id)", shop.get('id')) # DBG.enterShop wants a bare shop id
|
||||
pg.evaluate("(id) => window.DBG.enterShop({shopId:id})", shop.get('id'))
|
||||
pg.wait_for_timeout(700); sample()
|
||||
if pg.evaluate("() => window.DBG.info().mode === 'interior'"):
|
||||
shops_entered += 1
|
||||
@ -151,14 +124,10 @@ def main():
|
||||
fails.append(f"crossed only {len(chunks_seen)} chunks (need ≥{MIN_CHUNKS})")
|
||||
if shops_entered < MIN_SHOPS:
|
||||
fails.append(f"entered only {shops_entered} shops (need ≥{MIN_SHOPS})")
|
||||
# Budget is INFORMATIONAL here, not a hard gate: DBG.info() renders via composer.render() and can be
|
||||
# sampled mid chunk-stream / interior-mode transition, spiking the peak to impossible values (10k+
|
||||
# draws). The authoritative ≤300-draws / ≤200k-tris check is QA gate 3 — a clean single-pass
|
||||
# renderer.render(scene,camera) at the busiest intersection (see LANE_F_NOTES §4/§7).
|
||||
if peak_draws > BUDGET_DRAWS:
|
||||
warns.append(f"peak draws {peak_draws} > {BUDGET_DRAWS} (DBG.info sample — verify with gate 3's clean pass)")
|
||||
fails.append(f"draw calls peaked at {peak_draws} (budget {BUDGET_DRAWS})")
|
||||
if peak_tris > BUDGET_TRIS:
|
||||
warns.append(f"peak tris {peak_tris} > {BUDGET_TRIS} (rig-fleet models; gate 3 authoritative — decimate note for D/E)")
|
||||
fails.append(f"tris peaked at {peak_tris} (budget {BUDGET_TRIS})")
|
||||
# JS heap: only meaningful if DBG.info() actually reports a nonzero heapMB (needs
|
||||
# performance.memory / --expose-gc). If it doesn't, say so — don't silently "pass".
|
||||
nonzero_heap = [h for h in heap_series if h]
|
||||
|
||||
@ -647,86 +647,6 @@
|
||||
],
|
||||
"height": 0.6,
|
||||
"thumb": "thumbs/procity_fit_box_crate_01.png"
|
||||
},
|
||||
"listening_booth": {
|
||||
"file": "procity_fit_listening_booth_01.glb",
|
||||
"footprint": [
|
||||
0.88,
|
||||
0.7
|
||||
],
|
||||
"height": 1.595,
|
||||
"thumb": "thumbs/procity_fit_listening_booth_01.png"
|
||||
},
|
||||
"drinks_fridge": {
|
||||
"file": "procity_fit_drinks_fridge_01.glb",
|
||||
"footprint": [
|
||||
0.84,
|
||||
0.93
|
||||
],
|
||||
"height": 1.591,
|
||||
"thumb": "thumbs/procity_fit_drinks_fridge_01.png"
|
||||
},
|
||||
"arcade_cabinet": {
|
||||
"file": "procity_fit_arcade_cabinet_01.glb",
|
||||
"footprint": [
|
||||
0.75,
|
||||
0.85
|
||||
],
|
||||
"height": 1.692,
|
||||
"thumb": "thumbs/procity_fit_arcade_cabinet_01.png"
|
||||
},
|
||||
"milkshake_mixer": {
|
||||
"file": "procity_fit_milkshake_mixer_01.glb",
|
||||
"footprint": [
|
||||
0.38,
|
||||
0.27
|
||||
],
|
||||
"height": 0.498,
|
||||
"thumb": "thumbs/procity_fit_milkshake_mixer_01.png"
|
||||
},
|
||||
"counter_till": {
|
||||
"file": "procity_fit_counter_till_01.glb",
|
||||
"footprint": [
|
||||
1.61,
|
||||
0.61
|
||||
],
|
||||
"height": 1.146,
|
||||
"thumb": "thumbs/procity_fit_counter_till_01.png"
|
||||
},
|
||||
"glass_case": {
|
||||
"file": "procity_fit_glass_case_01.glb",
|
||||
"footprint": [
|
||||
1.08,
|
||||
1.42
|
||||
],
|
||||
"height": 0.997,
|
||||
"thumb": "thumbs/procity_fit_glass_case_01.png"
|
||||
},
|
||||
"magazine_rack": {
|
||||
"file": "procity_fit_magazine_rack_01.glb",
|
||||
"footprint": [
|
||||
0.54,
|
||||
0.44
|
||||
],
|
||||
"height": 1.2,
|
||||
"thumb": "thumbs/procity_fit_magazine_rack_01.png"
|
||||
},
|
||||
"crate_stack": {
|
||||
"file": "procity_fit_crate_stack_01.glb",
|
||||
"footprint": [
|
||||
0.71,
|
||||
0.72
|
||||
],
|
||||
"height": 0.9,
|
||||
"thumb": "thumbs/procity_fit_crate_stack_01.png"
|
||||
},
|
||||
"cash_register": {
|
||||
"file": "vintage-cash-register.glb",
|
||||
"footprint": [
|
||||
0.35,
|
||||
0.28
|
||||
],
|
||||
"height": 0.35
|
||||
}
|
||||
},
|
||||
"furniture": {
|
||||
@ -792,39 +712,12 @@
|
||||
],
|
||||
"height": 2.0,
|
||||
"thumb": "thumbs/procity_street_food_cart_01.png"
|
||||
},
|
||||
"novelty_record": {
|
||||
"file": "procity_street_novelty_record_01.glb",
|
||||
"footprint": [
|
||||
1.9,
|
||||
0.93
|
||||
],
|
||||
"height": 1.992,
|
||||
"thumb": "thumbs/procity_street_novelty_record_01.png"
|
||||
},
|
||||
"bus_shelter": {
|
||||
"file": "procity_street_bus_shelter_01.glb",
|
||||
"footprint": [
|
||||
2.33,
|
||||
1.41
|
||||
],
|
||||
"height": 2.4,
|
||||
"thumb": "thumbs/procity_street_bus_shelter_01.png"
|
||||
},
|
||||
"bin": {
|
||||
"file": "procity_street_bin_01.glb",
|
||||
"footprint": [
|
||||
0.57,
|
||||
0.68
|
||||
],
|
||||
"height": 1.0,
|
||||
"thumb": "thumbs/procity_street_bin_01.png"
|
||||
}
|
||||
},
|
||||
"fittingAliases": {
|
||||
"record_bins": "record_crate",
|
||||
"crates": "box_crate",
|
||||
"counter": "counter_till",
|
||||
"counter": "counter",
|
||||
"clothes_racks": "clothes_rack",
|
||||
"bricabrac_shelving": "wire_shelf",
|
||||
"book_wall": "bookshelf",
|
||||
@ -832,11 +725,6 @@
|
||||
"display_tables": "coffee_table",
|
||||
"bookshelves": "bookshelf",
|
||||
"vhs_shelving": "wire_shelf",
|
||||
"trestle_tables": "work_table",
|
||||
"glass_case": "glass_case",
|
||||
"glass_cabinets": "glass_case",
|
||||
"magazine_rack": "magazine_rack",
|
||||
"fridge": "drinks_fridge",
|
||||
"listening_corner": "listening_booth"
|
||||
"trestle_tables": "work_table"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 40 KiB |