diff --git a/B-progress.md b/B-progress.md
new file mode 100644
index 0000000..e4e15f7
--- /dev/null
+++ b/B-progress.md
@@ -0,0 +1,111 @@
+# PROCITY-B — progress (Lane B: Streetscape + game shell)
+
+**Status: complete and verified.** Lane B turns a CityPlan into a walkable, chunk-streamed 3D town
+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.
+
+## 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**
+ (≤300 gate, ~40 margin). Bonus: **skin materials 25 → 5** (the atlas is one shared facade material),
+ fixing the "≤25 was at the cap" concern too. (The old "~445" peak was largely a measurement artifact
+ — orphan chunks from teleport-jumping that the real streamer disposes.)
+- **`window.DBG` harness exposed** (`?dbg=1`, `js/world/dbg.js`) — answers LANE_F_NOTES §4 so Fable's
+ `shots.py`/`soak.py` can drive the game: `ready`, `shot(name)`, `teleport`, `setSegment`,
+ `enterShop`/`exitShop` (drives the real B→F→C interior chain), `info()`.
+- **`ChunkManager` lifecycle hooks** (`onChunkBuilt`/`onChunkDisposed`, optional/inert) — answers §8.
+- Verified: fixture ("Yarraville Junction", 26 shops) still builds clean with all 3 awning colours;
+ shadow path (`?shadows=1`) renders from the merged meshes; day + night + door tooltip + interiors
+ all work; **zero console errors**. Files touched: `skins.js`, `buildings.js`, `chunks.js`, `hud.js`,
+ new `dbg.js`, + a small guarded DBG loader in `index.html`. **Uncommitted** (per the lane treaty;
+ and Fable is concurrently wiring `index.html` — our edits coexist cleanly on stable anchors).
+
+Owned/created (only `web/index.html` + `web/js/world/*`, per the ownership treaty):
+
+| file | role |
+|---|---|
+| `web/index.html` | shell: importmap→vendor, renderer + ACES + bloom, MODE state machine (`map`/`street`/`interior`), main loop, adaptive perf, guarded `generatePlan` import, shot harness |
+| `web/js/world/fixture_plan.js` | hand-authored CityPlan (schema v1): 4 N + 2 S terraced blocks (26 shops), cross-street houses, 2 market stalls, streets graph — spans ~6 chunks so streaming is exercised |
+| `web/js/world/planutil.js` | chunk math (64 m), `chunkIndex`, node/lot indices, oriented-rect collider + `pushOutRect` |
+| `web/js/world/skins.js` | city-wide shared material registry (one material per skin), seeded flat-colour fallbacks |
+| `web/js/world/buildings.js` | facade kit: instanced shells/parapets/posts/awnings, merged per-skin facades, per-chunk sign atlas, doors/windows |
+| `web/js/world/ground.js` | roads/footpaths/kerbs/plaza/base plane, merged strips, UV-baked tiling |
+| `web/js/world/furniture.js` | instanced streetlights (emissive at night), benches, bins, gum trees, bus stops — all with primitive fallbacks |
+| `web/js/world/lighting.js` | seeded sky dome + 6-segment day/night cycle, one following sun/shadow, fog pop-in mask |
+| `web/js/world/player.js` | PointerLock WASD+run, eye 1.7 m, push-out collision vs nearby-chunk colliders |
+| `web/js/world/chunks.js` | the streamer: radius-R build / R+1 dispose, nearest-first queue, 4 ms/frame budget, night propagation |
+| `web/js/world/hud.js` | crosshair, throttled door-raycast tooltip, live draws/tris/fps/seed/clock readout, click→`procity:enterShop` |
+| `web/js/world/minimap.js` | `map` mode: 2D top-down town directory (streets + lots by type + player dot) |
+
+## Acceptance — all met
+
+- ✅ Walk the fixture 5 min: contiguous, no seams, chunks stream silently, day/night cycles, signs
+ legible, tooltips work, budgets green (148 scene draws, 21 skin materials, 5.8k tris).
+- ✅ Works with `generatePlan` when present (guarded import; falls back to fixture if absent).
+- ✅ Runs with `web/assets/` renamed away — no crashes, flat-colour fallback, canvas signs still show.
+- ✅ Instancing from day one, per-chunk 2048-wide sign atlas, canvas text (no fonts), seeded-everything.
+- ✅ 3 reference stills in `docs/shots/laneB/`; measured numbers in `docs/LANES/LANE_B_NOTES.md`.
+
+## Verified live (in-browser)
+
+Streaming (chunks build/dispose as you walk, queue never starves), 6-segment day→night with glowing
+shopfront windows + streetlamp bloom at night, door raycast → correct shop → `procity:enterShop` +
+toast, pixel-accurate collision (stops at wall − player radius), all-fallback mode, and Lane A's full
+493-shop city rendering + streaming + arbitrary-angle collision through Lane B.
+
+## Perf (measured — full table in `docs/LANES/LANE_B_NOTES.md`)
+
+- **Fixture** (radius 3, shadows on): ~148 scene draws, low tris, well under all gates.
+- **Lane A city** (~493 shops, radius 2 auto, shadows off): worst continuous-walk gameplay view
+ **~261 total draws** (≤300 gate, ~40 margin); typical ~130–210. Skin materials **5** (≤25).
+ Streetscape-only; Fable's integrated measure with citizens (pop 140) is ~191, also under gate.
+
+## Adversarial multi-agent review → fixes landed
+
+I ran a 4-dimension review (correctness / memory-perf / spec / robustness) with adversarial
+verification. Confirmed real bugs, all fixed and re-verified:
+
+1. **Collision was wrong for non-axis-aligned `ry`** (`pushOutRect` used the wrong rotation sign).
+ Latent on my axis-aligned fixture; would let you walk through Lane A's angled buildings. **Fixed** —
+ verified `keptOut:true` against a `ry=−1.32` lot.
+2. **House doors** were raycastable and mis-resolved to the nearest shop → clicking a house "entered"
+ a random shop. **Fixed** (separate non-picked mesh).
+3. **Awning skin** used a signed shift → undefined skin for ~⅓ of shops. **Fixed** (`>>>`).
+4. **Sign atlas** was a fixed 2048² per shop-chunk regardless of sign count → texture-memory blowup at
+ Lane A density. **Fixed** (sized to actual signs, mipmaps off).
+5. **Furniture**: numeric edge ids broke the seed hash (all edges identical); offset furniture could
+ land in an unbuilt chunk and vanish. **Both fixed**.
+6. **`takeShots()`** didn't restore the pre-shot mode. **Fixed**.
+
+## ⚠️ Things Fable should know (cross-lane)
+
+- **Lane A landed and is now the shipped default.** `index.html` prefers `generatePlan(seed)` over the
+ fixture. Seed 20261990 → "Boolarra Heads", ~493 shops. The fixture remains the fallback + schema ref.
+- **A "Lane F integration fix" already appeared in `skins.js`** (`facadeMat` now strips a `.jpg`/`facade-`
+ prefix). Lane A stores `shop.facadeSkin` as a **full filename** (`"facade-stucco-pink.jpg"`), not the
+ bare skin key the CITY_SPEC table implies. That normalize is what makes Lane A's facades load. Kept.
+- **Schema drift between fixture and `generatePlan`** (Lane B tolerates all of it, but CITY_SPEC should
+ pick one and say so): `lot.id`/`lot.block` are strings in the fixture, **numbers** from Lane A;
+ `lot.frontEdge` is `'N'/'S'` in the fixture, a **number** from Lane A; `shop.facadeSkin` is a bare key
+ in the fixture, a **filename** from Lane A; `lot.ry` is axis-aligned in the fixture, **arbitrary
+ radians** from Lane A. Recommend Fable freeze these in CITY_SPEC so Lane C/D/E don't each guess.
+- **≤25 skin-materials gate — RESOLVED (session 2).** Was at the cap (some seeds 27); the facade
+ atlas collapses ~22 facade materials into 1 shared atlas material, so it's now **5** city-wide.
+- **Perf gate on the full city — RESOLVED (session 2).** The facade texture atlas landed (collapses
+ every chunk's facades → 1 draw and ~22 facade materials → 1) plus awning + shell/trim instanced
+ merges. Worst gameplay view is now ~261 total (≤300). The flat-colour fallback is preserved (each
+ atlas slot is pre-painted with its skin's fallback colour before the JPEG loads). See LANE_B_NOTES.
+
+## Suggested next instructions (your call)
+
+1. Freeze the CityPlan schema in CITY_SPEC (the drift list above) — unblocks C/D/E. **Still open.**
+2. ~~Decide the facade-atlas~~ — **done (session 2): Lane B implemented it.** Draw + material gates now green.
+3. ~~Interiors hand-off~~ — **done: Fable wired `enterShop → interiorMode → buildInterior` in the shell;**
+ verified via `DBG.enterShop`. Lane B's seam (`procity:enterShop`/`procity:exitShop`) is intact.
+4. Run Lane F's `shots.py`/`soak.py` against `window.DBG` now that it exists (bookmarks are seed-derived).
+5. Optional: capture fresh `docs/shots/laneB/` stills of the generated city via `DBG.shot(...)`; wire
+ `map` mode into Lane A's `map.html` (currently my lightweight minimap).
+
+## Run
+`cd web && python3 -m http.server 8130` → http://localhost:8130 . (The app needs no server-side logic;
+the P-key shot harness uses browser downloads.)
diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md
new file mode 100644
index 0000000..568f831
--- /dev/null
+++ b/docs/LANES/LANE_B_NOTES.md
@@ -0,0 +1,147 @@
+# LANE B — NOTES (measured)
+
+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
+totals across all passes** (scene + bloom + output). "scene draws" below excludes the ~11
+post-processing passes; "total" adds them.
+
+## Perf — fixture (`fixture_plan.js`, "Yarraville Junction", 26 shops, radius 3, shadows on)
+
+| metric | measured | gate | status |
+|---|---|---|---|
+| draw calls | **148 scene (~159 total)** | ≤ 300 | ✅ |
+| triangles | **~5.8k** | ≤ 200k | ✅ |
+| skin materials (city-wide, shared) | **21** | ≤ 25 | ✅ |
+| live chunks (radius 3) | 12 | — | — |
+| chunk build | ~1–8 ms | 4 ms/frame budget | drained 1/frame |
+
+Walked the fixture end-to-end (~310 m of main street + the backstreets): contiguous, no seams,
+chunks stream in/out silently (live count 8↔12 as you move; build queue never starved), day/night
+cycles through 6 segments, signs legible, door tooltips + `procity:enterShop` fire, collision
+stops the player at wall−radius (measured 10.62 m against an 11 m frontage, radius 0.38). Runs with
+`web/assets/` renamed away — no crashes, flat-colour fallback town, canvas signs still legible.
+
+## Perf — Lane A integration (`generatePlan`, "Boolarra Heads", ~493 shops, 1 km², radius 2 auto, shadows off)
+
+Lane A's `generatePlan` landed mid-build; `index.html` prefers it over the fixture (guarded import),
+so this is the shipped default now. It is a full city with content in nearly every chunk — far
+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).
+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 |
+|---|---|---|---|
+| draw calls (worst gameplay view, continuous walk) | **~261 total (249 scene)** | ≤ 300 | ✅ ~40 draw margin |
+| draw calls (typical street view) | **~130–210 total** | ≤ 300 | ✅ |
+| triangles | 12k–25k | ≤ 200k | ✅ (huge headroom) |
+| skin materials (shared, city-wide) | **5** | ≤ 25 | ✅ (facade atlas collapsed ~22 facade mats → 1) |
+| live chunks (radius 2, walking) | ≤ 25 | — | — |
+| chunk build | min ~1 / median ~8 / max ~22 ms | 4 ms/frame | ~1 heavy chunk/frame |
+
+Integration verified: Lane B streams and renders Lane A's 493-shop city correctly — facades, signs,
+doors, ground, furniture, day/night all work. Collision holds for the arbitrary lot orientations
+`generatePlan` emits (e.g. `ry = −1.32 rad`); see the collision fix below.
+
+### Adaptive settings (index.html)
+`BIG_CITY = plan.shops.length > 120`. Big city → **radius 2 + no sun-shadow pass**; fixture → radius
+3 + shadows. Override with `?r=N` / `?shadows=0|1`. Dropping the shadow pass ≈ halves draw calls in
+dense areas (the shadow map re-renders every caster) for little visual loss in a flat-lit low-poly
+town — LANE_B doc explicitly allows "none".
+
+### Draw-budget close-out (this session) — the gate now holds with margin
+Fable's first integrated smoke test read **~334 draws at spawn** (that was radius 3, pre-adaptive) and
+the deep districts appeared to peak ~445. Two things closed the gap:
+
+1. **Facade texture atlas** (`skins.js` + `buildings.js`). Every facade skin packs into one 2048²
+ atlas (6×6 slots, each pre-painted with the skin's flat fallback colour, then the JPEG drawn over
+ it — house law preserved), so a chunk's facades merge into **one** mesh (was ~5–8 per-skin meshes)
+ and ~22 facade materials collapse to **1** (this also fixes the "≤25 materials was at the cap /
+ over for some seeds" concern — now **5** city-wide). UV convention mirrors the sign atlas.
+2. **Instanced-mesh merges.** Awnings were one InstancedMesh *per skin* (red/green/blue) per chunk;
+ now **one per chunk** with per-instance colour (the stripe JPEG lived on the awning's unseen top
+ face, so flat colour reads the same under the verandah). Shells + parapets + posts merged into
+ **one** InstancedMesh (per-instance colour) instead of two.
+
+Measured effect (worst dense frustum): **393 → 265 scene draws**; worst *continuous-walk* gameplay
+view **~261 total**. Note the earlier "445" figures were partly a **measurement artifact** — teleport-
+jumping between distant spots left orphan chunks the real streamer disposes; a proper walk never
+exceeds the radius-2 set. Verified day + night, facades still textured, awning colours still varied.
+
+## Architecture decisions worth knowing (for Lane F)
+
+- **Ground is built once, not streamed.** The street graph is a handful of edges; merged flat quads
+ are a few draws total and can't seam. If Lane A ships a many-hundred-edge network, revisit.
+- **Sky/lighting are global**, re-centred on the player each frame (dome radius 650, sun follows).
+ Linear fog at `(radius+0.4)·64 m` doubles as the chunk pop-in mask.
+- **Per-chunk streaming = buildings + furniture only.** `ChunkManager` keeps chunks within Chebyshev
+ radius R, disposes past R+1, drains a nearest-first build queue under a 4 ms/frame budget. A single
+ dense chunk can exceed 4 ms (median 8), so it builds ~1/frame → an occasional ~8–22 ms build frame
+ entering a dense district (fog-masked). Splitting a chunk build across frames is the next step if
+ that hitch matters.
+- **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.
+- **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
+ canvas atlas sized to the chunk's actual sign count, mipmaps off. Net: ~1 draw per kind per chunk.
+- **Orientation law** (obeyed by `buildings.toWorld`, the InstancedMesh shells, and `pushOutRect`):
+ a lot at `ry=0` faces `+Z`; `local→world = RotY(ry)`. Collider and geometry share this convention.
+
+## Fixes applied after an adversarial multi-agent review (this session)
+
+- **Collision for arbitrary `ry`** (`planutil.pushOutRect`): world→local used the wrong rotation sign;
+ correct only for axis-aligned `ry`. The fixture is axis-aligned so it passed, but Lane A's angled
+ lots would let you walk through walls. Fixed to the true inverse of `toWorld`. **(Critical for Lane A.)**
+- **House doors** were merged into the interactive door mesh with no `doorRects` → raycasting a house
+ door mis-resolved to the nearest shop. Now a separate non-picked mesh.
+- **Awning skin index** used a signed shift (`seed >> 3`) → negative index → undefined skin for ~⅓ of
+ shops. Now `>>> 3`.
+- **Sign atlas** was a fixed 2048² (~16 MB canvas + ~22 MB GPU each) per shop-chunk regardless of
+ sign count. Now sized to the actual sign count, mipmaps off — critical at Lane A's density.
+- **Furniture edge hashing** assumed string edge ids; `generatePlan` uses numeric ids → every edge
+ got the same seed. Coerced with `String(id)`.
+- **Furniture drop:** `chunkIndex` rasterised only the edge centreline, so furniture at a perpendicular
+ offset could land in an unbuilt chunk and vanish. Rasterisation now covers the full road+verge band.
+- **`takeShots()`** never restored the pre-shot mode (P in map mode dumped you into street).
+
+Reviewers' non-issues (intentional): `getColliders` returns a shared scratch array (per-frame
+alloc avoidance); per-frame `{clock,chunks}` HUD object (negligible).
+
+## The `shot()` harness
+
+Press **P** → renders 3 fixed cameras and downloads PNGs. Reference stills captured to
+`docs/shots/laneB/` (main-street day, plaza overview, night strip). Note: at Lane A's city the fixed
+camera poses are tuned for the fixture; re-aim if used against the generated city.
+
+## `window.DBG` — the QA harness hook (this session, for Lane F's shots.py/soak.py)
+
+Loaded only with **`?dbg=1`** (`js/world/dbg.js`, installed by the shell). Answers LANE_F_NOTES §4.
+All poses are derived from the **live plan** so bookmarks work on any seed; the day cycle pauses on
+the first scripted segment change for deterministic captures.
+
+- `DBG.ready` — true once the first chunks are built + the queue is drained (textures may still decode).
+- `DBG.shot(name)` — snaps to a named bookmark, sets its time-of-day, settles bloom, returns stats.
+ Bookmarks: `street_noon`, `arcade` (dept anchor), `market_square` (stalls), `milkbar_dusk`, `night_neon`.
+- `DBG.teleport(x, z, ry)` / `DBG.setSegment(seg 0–5)` — drive the soak walk + time of day.
+- `DBG.enterShop(shopId)` / `DBG.exitShop()` — scripted interior visits (drives Lane F's bridge →
+ Lane C `buildInterior`; verified end-to-end: `enterShop` → mode `interior`, `exitShop` → back to street).
+- `DBG.info()` → `{ drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode }` (budget/soak read this).
+
+Every bookmark stays under budget (busiest, the dept anchor, ~269 draws). `fps` reads the HUD's
+smoothed value (0 under a throttled/background tab; populates in a foreground Playwright run).
+
+## `ChunkManager` lifecycle hooks (optional, LANE_F_NOTES §8)
+
+`createChunkManager` now fires `ctx.onChunkBuilt(key, {cx, cz, buildings, furniture, data})` and
+`ctx.onChunkDisposed(key, {cx, cz})` **if a consumer sets them on `ctx`** — a clean per-chunk seam
+for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
+`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
+
+## Controls
+WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
+`M` map · `P` screenshot · Esc release pointer.
diff --git a/docs/shots/laneB/.gitkeep b/docs/shots/laneB/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/shots/laneB/procity-laneB-main-street-day.png b/docs/shots/laneB/procity-laneB-main-street-day.png
new file mode 100644
index 0000000..0f73d13
Binary files /dev/null and b/docs/shots/laneB/procity-laneB-main-street-day.png differ
diff --git a/docs/shots/laneB/procity-laneB-plaza-overview.png b/docs/shots/laneB/procity-laneB-plaza-overview.png
new file mode 100644
index 0000000..2dcad5e
Binary files /dev/null and b/docs/shots/laneB/procity-laneB-plaza-overview.png differ
diff --git a/docs/shots/laneB/procity-laneB-strip-night.png b/docs/shots/laneB/procity-laneB-strip-night.png
new file mode 100644
index 0000000..98620de
Binary files /dev/null and b/docs/shots/laneB/procity-laneB-strip-night.png differ
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..20c5b16
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,256 @@
+
+
+
+
+
+PROCITY — walkable town
+
+
+
+
+
+
+
+
PROCITY
+
A procedurally generated, walkable 90s-Australian shopping town.
+ Every door opens (soon). Walk the strip, watch the day turn.
+
+
+
WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot
+
+
+
+
+
diff --git a/web/js/world/buildings.js b/web/js/world/buildings.js
new file mode 100644
index 0000000..e24b70e
--- /dev/null
+++ b/web/js/world/buildings.js
@@ -0,0 +1,362 @@
+// PROCITY Lane B — buildings.js
+// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
+// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
+// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
+// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
+// • merged door / window meshes (windows go emissive at night via applyNight)
+// Everything is built in world space and collected, so a whole chunk is ~10–18 draw calls.
+//
+// Canonical building: front face at local +Z, footprint centred at origin, then translate to the
+// lot centre (x,z) and rotate by lot.ry (see fixture_plan.js orientation note).
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { rng, pick, frange, irange } from '../core/prng.js';
+import { SHOP_REGISTRY } from './fixture_plan.js';
+import { lotCollider } from './planutil.js';
+
+const STOREY_H = 3.2;
+const FACADE_H = 3.0; // ground-floor shopfront height the skin covers
+const PARAPET_H = 0.5;
+const AWNING_DEPTH = 2.2; // reach over the footpath
+const AWNING_Y = 2.95;
+const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890'];
+const ROOF_TINT = '#5b5148';
+const AWNING_SKINS = ['red', 'green', 'blue'];
+
+const _m4 = new THREE.Matrix4();
+const _q = new THREE.Quaternion();
+const _up = new THREE.Vector3(0, 1, 0);
+const _s = new THREE.Vector3();
+const _p = new THREE.Vector3();
+
+// Build a flat quad (two tris) from four world-space corners + a UV rect. Corners CCW seen from +Y-ish.
+function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
+ const g = new THREE.BufferGeometry();
+ const pos = new Float32Array([
+ c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2],
+ c0[0], c0[1], c0[2], c2[0], c2[1], c2[2], c3[0], c3[1], c3[2],
+ ]);
+ const [u0, v0, u1, v1] = uv;
+ const uvs = new Float32Array([u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1]);
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
+ g.computeVertexNormals();
+ return g;
+}
+
+// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
+function toWorld(lot, lx, ly, lz, out) {
+ const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
+ out[0] = lot.x + (lx * cos + lz * sin);
+ out[1] = ly;
+ out[2] = lot.z + (-lx * sin + lz * cos);
+ return out;
+}
+
+/**
+ * buildChunkBuildings(chunkData, ctx) → chunk building bundle.
+ * chunkData: { lots:[], shops:[] }
+ * ctx: { skins, citySeed, night }
+ * returns { group, colliders, doorMesh|null, doorRects, applyNight, dispose }
+ */
+export function buildChunkBuildings(chunkData, ctx) {
+ const { skins } = ctx;
+ const group = new THREE.Group();
+ group.name = 'chunk-buildings';
+
+ const boxMats = []; // {matrix, color} — shells + parapets + posts, ONE instanced draw/chunk
+ const awnings = []; // {matrix, color} — all awning/canopy skins, ONE instanced draw/chunk
+ const facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
+ const doorGeos = []; // interactive shop/stall doors (raycast targets)
+ const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
+ const windowGeos = [];
+ const signQuads = []; // { geo }
+ const colliders = [];
+ const doorRects = []; // { shopId, name, x, z }
+
+ const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
+
+ // ── sign atlas: lay every sign in this chunk into one 2048² canvas ──
+ // Atlas sized to the chunk's ACTUAL sign count (not a fixed 2048²): one row per 4 signs, so a
+ // typical chunk holds a ~2048×168 strip, not a 16MB square. Mipmaps off (saves ⅓ GPU + NPOT-safe).
+ const COLS = 4, CELL_W = 512, CELL_H = 168, ATLAS_W = COLS * CELL_W; // 2048 wide
+ const N_SIGNS = chunkData.shops.length; // shops+stalls each get one
+ const ATLAS_ROWS = Math.max(1, Math.ceil(N_SIGNS / COLS));
+ const ATLAS_H = ATLAS_ROWS * CELL_H;
+ let atlasCanvas = null, actx = null; // still lazy — shopless chunks allocate nothing
+ let signCell = 0;
+ const signUV = new Map(); // shopId → [u0,v0,u1,v1]
+ function drawSign(shop) {
+ if (signCell >= COLS * ATLAS_ROWS) return null;
+ if (!atlasCanvas) {
+ atlasCanvas = document.createElement('canvas');
+ atlasCanvas.width = ATLAS_W; atlasCanvas.height = ATLAS_H;
+ actx = atlasCanvas.getContext('2d');
+ actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
+ }
+ const col = signCell % COLS, row = (signCell / COLS) | 0;
+ const px = col * CELL_W, py = row * CELL_H;
+ const reg = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;
+ actx.fillStyle = reg.signBg; actx.fillRect(px + 4, py + 4, CELL_W - 8, CELL_H - 8);
+ actx.fillStyle = reg.signFg;
+ actx.strokeStyle = reg.signFg; actx.lineWidth = 3;
+ actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
+ // fit text
+ let fs = 74; const label = String(shop.name).slice(0, 22).toUpperCase();
+ actx.textAlign = 'center'; actx.textBaseline = 'middle';
+ do { actx.font = `bold ${fs}px Arial, sans-serif`; fs -= 4; }
+ while (actx.measureText(label).width > CELL_W - 40 && fs > 22);
+ actx.fillText(label, px + CELL_W / 2, py + CELL_H / 2);
+ const uv = [px / ATLAS_W, 1 - (py + CELL_H) / ATLAS_H, (px + CELL_W) / ATLAS_W, 1 - py / ATLAS_H];
+ signUV.set(shop.id, uv);
+ signCell++;
+ return uv;
+ }
+
+ // ── per-lot build ──
+ for (const lot of chunkData.lots) {
+ const shop = shopByLot.get(lot.id);
+ if (lot.use === 'house') { buildHouse(lot); continue; }
+ if (lot.use === 'stall') { buildStall(lot, shop); continue; }
+ buildShopfront(lot, shop);
+ }
+
+ function buildShopfront(lot, shop) {
+ const seed = shop ? shop.seed : ctx.citySeed ^ 0x9e37;
+ const r = rng(seed, 'bld', 0);
+ const storeys = (shop && shop.storeys) || 1;
+ const h = storeys * STOREY_H;
+ const w = lot.w, d = lot.d;
+ const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
+
+ // shell (unit box scaled) centred at (x, h/2, z)
+ _p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC.clone() });
+
+ // parapet cap along the front
+ _p.set(lot.x, h + PARAPET_H / 2, lot.z); _s.set(w, PARAPET_H, d * 0.6);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
+
+ // facade plane on the ground-floor front (local +Z face) — UV-mapped into the shared facade atlas
+ const skin = (shop && shop.facadeSkin) || 'weatherboard';
+ const fh = Math.min(h, FACADE_H);
+ const zf = d / 2 + 0.02;
+ const hw = w * 0.48;
+ const c0 = toWorld(lot, -hw, 0.02, zf, []);
+ const c1 = toWorld(lot, hw, 0.02, zf, []);
+ const c2 = toWorld(lot, hw, fh, zf, []);
+ const c3 = toWorld(lot, -hw, fh, zf, []);
+ facadeGeos.push(quad(c0, c1, c2, c3, skins.facadeAtlasUV(skin)));
+
+ // door (dark) — centred, on the front, proud of the facade
+ const dw = 1.2, dh = 2.2, zd = zf + 0.03;
+ doorGeos.push(quad(
+ toWorld(lot, -dw / 2, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
+ toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, zd, []),
+ ));
+ if (shop) doorRects.push({ shopId: shop.id, name: shop.name, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
+
+ // windows flanking the door (tinted glass, emissive at night)
+ const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
+ const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
+ for (const ox of offs) {
+ windowGeos.push(quad(
+ toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
+ toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
+ ));
+ }
+
+ // sign band overlaying the blank signboard region of the skin
+ if (shop) {
+ drawSign(shop);
+ const uv = signUV.get(shop.id);
+ if (uv) {
+ const sh = 0.5, sy = fh - 0.35, sw = w * 0.92, zs = zf + 0.05;
+ signQuads.push(quad(
+ toWorld(lot, -sw / 2, sy - sh / 2, zs, []), toWorld(lot, sw / 2, sy - sh / 2, zs, []),
+ toWorld(lot, sw / 2, sy + sh / 2, zs, []), toWorld(lot, -sw / 2, sy + sh / 2, zs, []),
+ uv,
+ ));
+ }
+ }
+
+ // posted verandah + awning over the footpath (main-street shopfronts only)
+ if (lot.use === 'shop' || lot.use === 'anchor') {
+ const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
+ // awning box: thin slab reaching over the footpath at the front
+ _p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + AWNING_DEPTH / 2, [])));
+ _s.set(w, 0.16, AWNING_DEPTH);
+ awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
+ // two posts at the awning's outer edge
+ const postY = AWNING_Y / 2;
+ for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
+ _p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
+ _s.set(0.12, AWNING_Y, 0.12);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
+ }
+ }
+
+ colliders.push(lotCollider(lot));
+ }
+
+ function buildHouse(lot) {
+ const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
+ const r = rng(seed, 'house', 0);
+ const h = 2.7, w = lot.w, d = lot.d;
+ const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
+ _p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC });
+ // simple hip roof: a squashed, slightly oversized box on top
+ _p.set(lot.x, h + 0.5, lot.z); _s.set(w + 0.6, 1.0, d + 0.6);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
+ // front door + two windows (decorative — houses are not enterable, so keep the door out of
+ // the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop)
+ const zf = d / 2 + 0.02;
+ decorDoorGeos.push(quad(
+ toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, zf, []),
+ toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
+ ));
+ for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
+ windowGeos.push(quad(
+ toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
+ toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
+ ));
+ }
+ colliders.push(lotCollider(lot));
+ }
+
+ function buildStall(lot, shop) {
+ const seed = shop ? shop.seed : 4242;
+ const w = lot.w, d = lot.d;
+ // trestle table
+ _p.set(lot.x, 0.4, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, 0.8, d);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#6b4a30') });
+ // striped canopy (awning skin) above
+ const askin = AWNING_SKINS[seed % AWNING_SKINS.length];
+ _p.set(lot.x, 2.2, lot.z); _s.set(w + 0.6, 0.14, d + 0.8);
+ awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
+ // corner posts
+ for (const [ox, oz] of [[-w / 2, -d / 2], [w / 2, -d / 2], [-w / 2, d / 2], [w / 2, d / 2]]) {
+ _p.fromArray(toWorld(lot, ox, 1.1, oz, [])); _s.set(0.08, 2.2, 0.08);
+ boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#3a3a3a') });
+ }
+ // interaction plane on the front so you can "enter" the stall
+ if (shop) {
+ const zf = d / 2 + 0.02;
+ doorGeos.push(quad(
+ toWorld(lot, -w / 2, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
+ toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, zf, []),
+ ));
+ doorRects.push({ shopId: shop.id, name: shop.name, x: lot.x, z: lot.z });
+ drawSign(shop);
+ const uv = signUV.get(shop.id);
+ if (uv) {
+ signQuads.push(quad(
+ toWorld(lot, -w / 2, 2.0, zf + 0.05, []), toWorld(lot, w / 2, 2.0, zf + 0.05, []),
+ toWorld(lot, w / 2, 2.4, zf + 0.05, []), toWorld(lot, -w / 2, 2.4, zf + 0.05, []),
+ uv,
+ ));
+ }
+ }
+ colliders.push(lotCollider(lot));
+ }
+
+ // ── finalise instanced meshes ──
+ const disposables = [];
+ function makeInstanced(list, material, castShadow) {
+ if (!list.length) return null;
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const im = new THREE.InstancedMesh(geo, material, list.length);
+ for (let i = 0; i < list.length; i++) {
+ im.setMatrixAt(i, list[i].matrix);
+ if (list[i].color && im.instanceColor !== undefined) im.setColorAt(i, list[i].color);
+ }
+ im.instanceMatrix.needsUpdate = true;
+ if (im.instanceColor) im.instanceColor.needsUpdate = true;
+ im.castShadow = !!castShadow; im.receiveShadow = true;
+ im.frustumCulled = true;
+ disposables.push(geo);
+ group.add(im);
+ return im;
+ }
+ makeInstanced(boxMats, skins.shellMaterial(), true); // shells + parapets + posts (per-instance colour)
+ makeInstanced(awnings, skins.awningMaterial(), true); // every awning/canopy skin (per-instance colour)
+
+ // ── finalise the facade: ALL skins merge into ONE mesh via the shared atlas (1 draw/chunk) ──
+ if (facadeGeos.length) {
+ const merged = mergeGeometries(facadeGeos, false);
+ facadeGeos.forEach((g) => g.dispose());
+ const mesh = new THREE.Mesh(merged, skins.facadeAtlasMaterial());
+ mesh.receiveShadow = true; mesh.castShadow = false;
+ disposables.push(merged);
+ group.add(mesh);
+ }
+
+ // ── doors ──
+ const doorMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
+ disposables.push(doorMat);
+ // interactive shop/stall doors — the HUD raycasts this mesh and maps hits to doorRects
+ let doorMesh = null;
+ if (doorGeos.length) {
+ const merged = mergeGeometries(doorGeos, false);
+ doorGeos.forEach((g) => g.dispose());
+ doorMesh = new THREE.Mesh(merged, doorMat);
+ doorMesh.userData.isDoorMesh = true;
+ doorMesh.userData.doorRects = doorRects;
+ disposables.push(merged);
+ group.add(doorMesh);
+ }
+ // decorative house doors — same look, but no userData so the HUD never picks them
+ if (decorDoorGeos.length) {
+ const merged = mergeGeometries(decorDoorGeos, false);
+ decorDoorGeos.forEach((g) => g.dispose());
+ const mesh = new THREE.Mesh(merged, doorMat);
+ disposables.push(merged);
+ group.add(mesh);
+ }
+
+ // ── windows (merged, emissive at night) ──
+ let windowMat = null;
+ if (windowGeos.length) {
+ const merged = mergeGeometries(windowGeos, false);
+ windowGeos.forEach((g) => g.dispose());
+ windowMat = new THREE.MeshStandardMaterial({
+ color: 0x28323c, roughness: 0.14, metalness: 0.35,
+ transparent: true, opacity: 0.36, // subtle glass in day so the facade art shows through
+ emissive: new THREE.Color(0xffcf94), emissiveIntensity: 0,
+ });
+ const mesh = new THREE.Mesh(merged, windowMat);
+ disposables.push(merged, windowMat);
+ group.add(mesh);
+ }
+
+ // ── signs (merged, one atlas texture) ──
+ if (signQuads.length) {
+ const merged = mergeGeometries(signQuads.map((s) => s.geo || s), false);
+ signQuads.forEach((s) => (s.geo || s).dispose());
+ const tex = new THREE.CanvasTexture(atlasCanvas);
+ tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
+ tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // ⅓ less GPU mem, NPOT-safe
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
+ const mesh = new THREE.Mesh(merged, mat);
+ disposables.push(merged, tex, mat);
+ group.add(mesh);
+ }
+
+ function applyNight(night) {
+ if (windowMat) { windowMat.emissiveIntensity = night ? 0.95 : 0; windowMat.opacity = night ? 0.9 : 0.36; }
+ }
+ applyNight(!!ctx.night);
+
+ function dispose() {
+ group.traverse((o) => {
+ if (o.isInstancedMesh) o.dispose && o.dispose(); // frees instanceMatrix/instanceColor
+ if (o.isMesh || o.isInstancedMesh) o.geometry && o.geometry.dispose && o.geometry.dispose();
+ });
+ for (const d of disposables) d.dispose && d.dispose();
+ group.clear();
+ }
+
+ return { group, colliders, doorMesh, doorRects, applyNight, dispose };
+}
diff --git a/web/js/world/chunks.js b/web/js/world/chunks.js
new file mode 100644
index 0000000..956ff10
--- /dev/null
+++ b/web/js/world/chunks.js
@@ -0,0 +1,132 @@
+// PROCITY Lane B — chunks.js
+// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
+// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
+// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
+// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
+
+import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
+import { buildChunkBuildings } from './buildings.js';
+import { buildChunkFurniture } from './furniture.js';
+
+const BUILD_BUDGET_MS = 4;
+
+export function createChunkManager(plan, scene, ctx) {
+ const R = ctx.radius ?? 3;
+ const DISPOSE_R = R + 1;
+ const index = chunkIndex(plan);
+
+ const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
+ const doorMeshes = []; // live door meshes (for HUD raycast)
+ let queue = []; // keys pending build, nearest-first
+ let queued = new Set();
+ let lastCx = null, lastCz = null;
+ let lastBuildMs = 0; // rolling measure for the HUD/notes
+
+ const _colliders = []; // scratch, refilled each getColliders()
+
+ function buildChunk(key) {
+ if (live.has(key)) return;
+ const data = index.get(key);
+ if (!data) return;
+ const { cx, cz } = parseKey(key);
+ const t0 = performance.now();
+ const buildings = buildChunkBuildings(data, ctx);
+ const furniture = buildChunkFurniture(data, ctx, cx, cz);
+ lastBuildMs = performance.now() - t0;
+ scene.add(buildings.group, furniture.group);
+ if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
+ live.set(key, { buildings, furniture, colliders: buildings.colliders, cx, cz });
+ // optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
+ // Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
+ if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
+ }
+
+ function disposeChunk(key) {
+ const b = live.get(key);
+ if (!b) return;
+ if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
+ scene.remove(b.buildings.group, b.furniture.group);
+ if (b.buildings.doorMesh) {
+ const i = doorMeshes.indexOf(b.buildings.doorMesh);
+ if (i >= 0) doorMeshes.splice(i, 1);
+ }
+ b.buildings.dispose();
+ b.furniture.dispose();
+ live.delete(key);
+ }
+
+ function recompute(pcx, pcz) {
+ // dispose anything beyond R+1
+ for (const key of [...live.keys()]) {
+ const { cx, cz } = parseKey(key);
+ if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
+ }
+ // enqueue desired-but-missing, nearest first
+ const want = [];
+ for (let dz = -R; dz <= R; dz++) {
+ for (let dx = -R; dx <= R; dx++) {
+ const key = chunkKey(pcx + dx, pcz + dz);
+ if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
+ }
+ }
+ want.sort((a, b) => a.d - b.d);
+ queue = want.map((w) => w.key);
+ queued = new Set(queue);
+ }
+
+ function drainQueue() {
+ if (!queue.length) return;
+ const t0 = performance.now();
+ while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
+ const key = queue.shift();
+ queued.delete(key);
+ buildChunk(key);
+ }
+ }
+
+ function update(playerPos) {
+ const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
+ if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
+ drainQueue();
+ }
+
+ // Prebuild everything within radius synchronously (used once at spawn so the player never
+ // starts inside an unbuilt void).
+ function warmup(playerPos) {
+ const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
+ lastCx = pcx; lastCz = pcz;
+ recompute(pcx, pcz);
+ while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
+ }
+
+ // Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
+ function getColliders(x, z) {
+ const pcx = chunkCoord(x), pcz = chunkCoord(z);
+ _colliders.length = 0;
+ for (let dz = -1; dz <= 1; dz++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const b = live.get(chunkKey(pcx + dx, pcz + dz));
+ if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
+ }
+ }
+ return _colliders;
+ }
+
+ function setNight(night) {
+ ctx.night = night;
+ for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
+ }
+
+ function dispose() {
+ for (const key of [...live.keys()]) disposeChunk(key);
+ queue = []; queued.clear();
+ }
+
+ return {
+ update, warmup, getColliders, setNight, dispose,
+ getDoorMeshes: () => doorMeshes,
+ get count() { return live.size; },
+ get pending() { return queue.length; },
+ get lastBuildMs() { return lastBuildMs; },
+ };
+}
diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js
new file mode 100644
index 0000000..acc3f89
--- /dev/null
+++ b/web/js/world/dbg.js
@@ -0,0 +1,114 @@
+// PROCITY Lane B — dbg.js
+// The QA debug harness hook (window.DBG), installed by the shell only with ?dbg=1. It drives Lane
+// F's tools/shots.py + tools/soak.py (LANE_F_NOTES §4) and mirrors 90sDJsim's window.DBG.shot()
+// contract: named camera bookmarks, teleport, time-of-day, scripted shop entry, and a perf/heap
+// readout. Every pose is derived from the LIVE plan so bookmarks work on any seed; the day/night
+// cycle is paused on the first scripted segment change so captures are deterministic.
+//
+// Lives in its own module (not the shell) so it can grow without touching index.html — the shell
+// just calls installDBG(deps) with the systems it owns when ?dbg=1 is present.
+
+export function installDBG(deps) {
+ const { plan, camera, renderer, composer, chunks, lighting, player, hud,
+ setMode, getMode, enterShop } = deps;
+ const CS = 64;
+
+ // reset the perf counters, then render one full composited frame (bloom + output) so the harness
+ // captures exactly this frame and renderer.info reflects it — robust even when rAF is throttled.
+ const render = () => { renderer.info.reset(); composer.render(); };
+
+ const info = () => {
+ const r = renderer.info;
+ return {
+ drawCalls: r.render.calls, tris: r.render.triangles,
+ fps: hud && hud.getFps ? hud.getFps() : null,
+ heapMB: (performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null),
+ geometries: r.memory.geometries, textures: r.memory.textures,
+ chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
+ chunks: chunks.count, mode: getMode(),
+ };
+ };
+
+ // ── camera pose facing a shop's front ──
+ // Front normal = local +Z rotated by lot.ry; the shopfront sits d/2 out from the lot centre, so we
+ // stand `dist` m IN FRONT OF THE SHOPFRONT (not the centre — deep lots like the dept anchor would
+ // otherwise put the camera inside the facade), offset slightly for a 3/4 angle, looking at it.
+ const lotOf = (shop) => plan.lots?.find((l) => l.id === shop.lot);
+ const poseForShop = (shop, dist = 10) => {
+ const lot = shop && lotOf(shop); if (!lot) return null;
+ const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); // front normal
+ const rx = Math.cos(ry), rz = -Math.sin(ry); // right vector
+ const half = (lot.d || 8) / 2, off = 1.0; // near dead-on (terraced neighbours flank the frame)
+ const frontX = lot.x + fx * half, frontZ = lot.z + fz * half; // shopfront centre
+ return {
+ pos: [frontX + fx * dist + rx * off, 2.0, frontZ + fz * dist + rz * off],
+ look: [frontX, 2.2, frontZ],
+ };
+ };
+ const byDist = () => [...(plan.shops || [])].sort((a, b) => {
+ const la = lotOf(a), lb = lotOf(b);
+ return Math.hypot(la?.x || 0, la?.z || 0) - Math.hypot(lb?.x || 0, lb?.z || 0);
+ });
+ // nearest shop matching pred, else nearest shop overall → a pose is always produced
+ const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
+
+ // Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
+ // Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
+ // to the nearest shop, so every bookmark always yields a valid street pose.
+ const BOOKMARKS = {
+ street_noon: { seg: 2, resolve: () => pickShop(() => true) },
+ arcade: { seg: 3, resolve: () => pickShop((s) => s.type === 'dept' || /arcade/.test(s.type)) },
+ market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
+ milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
+ night_neon: { seg: 5, resolve: () => pickShop((s) => s.type === 'record' || s.type === 'video') },
+ };
+
+ const DBG = {
+ // true once the initial chunks are built and the queue is drained (textures may still decode)
+ get ready() { return chunks.count > 0 && chunks.pending === 0; },
+ bookmarks: Object.keys(BOOKMARKS),
+ info,
+
+ // time-of-day (hands the day cycle to the harness so captures are deterministic)
+ setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
+
+ // drive the soak walk: place the player and stream the destination in before the next step/shot
+ teleport(x, z, ry = 0) {
+ if (getMode() !== 'street') setMode('street');
+ player.teleport(x, z, ry);
+ chunks.warmup(player.position);
+ render();
+ return info();
+ },
+
+ // snap to a named bookmark, settle, and return the frame's stats
+ shot(name) {
+ const bm = BOOKMARKS[name] || BOOKMARKS.street_noon;
+ const pose = bm.resolve();
+ if (getMode() !== 'street') setMode('street');
+ if (bm.seg != null) { lighting.setPaused(true); lighting.setSegment(bm.seg); }
+ if (pose) {
+ player.teleport(pose.pos[0], pose.pos[2], 0);
+ camera.position.set(pose.pos[0], pose.pos[1], pose.pos[2]);
+ chunks.warmup(camera.position);
+ camera.lookAt(pose.look[0], pose.look[1], pose.look[2]);
+ camera.updateMatrixWorld();
+ }
+ render(); render(); // two frames so bloom + the segment change settle before capture
+ return { name, ...info() };
+ },
+
+ // scripted interior visit (drives the record_interior shot + the soak memory-baseline gate)
+ enterShop(shopId) {
+ const s = (plan.shops || []).find((x) => x.id === shopId);
+ if (!s) return { error: `no shop ${shopId}` };
+ enterShop(s.id, s.name);
+ return { entered: s.id, name: s.name };
+ },
+ exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
+ };
+
+ window.DBG = DBG;
+ console.log('[procity] DBG harness ready (?dbg=1) — shot/teleport/setSegment/enterShop/exitShop/info/ready');
+ return DBG;
+}
diff --git a/web/js/world/fixture_plan.js b/web/js/world/fixture_plan.js
new file mode 100644
index 0000000..92e3219
--- /dev/null
+++ b/web/js/world/fixture_plan.js
@@ -0,0 +1,211 @@
+// PROCITY Lane B — fixture_plan.js
+// A hand-authored CityPlan (CITY_SPEC schema v1) so the streetscape is buildable *before*
+// Lane A ships generatePlan(). Everything here is deterministic layout math (no Math.random,
+// no THREE) — pure JSON-serialisable data. When Lane A lands, index.html swaps in
+// generatePlan(seed) and this file becomes the fallback + schema reference.
+//
+// LAYOUT (metres, +Y up, ground = XZ, origin = centre of the main square):
+//
+// north (backstreets: houses up the cross street)
+// │ cross st (X=0)
+// NW shops (Z=+11, face −Z) ──────┼────── NE shops
+// ═══════════════ MAIN STREET (Z=0, east-west) ═══════════════
+// SW shops (Z=−11, face +Z) ──────┼────── SE shops
+//
+// Orientation convention (documented once, obeyed by buildings.js):
+// A lot is an oriented rectangle centred (x,z), footprint w (frontage) × d (depth).
+// `ry` rotates the building about +Y. At ry=0 the shopfront outward-normal is +Z.
+// Rotation by ry maps +Z → (sin ry, 0, cos ry), so:
+// front faces +Z → ry = 0 (south side of an E-W street)
+// front faces −Z → ry = π (north side of an E-W street)
+// front faces +X → ry = +π/2 (west side of a N-S street)
+// front faces −X → ry = −π/2 (east side of a N-S street)
+
+import { rng, pick, irange } from '../core/prng.js';
+
+// ── Shop-type registry (Lane A owns web/js/core/registry.js; it hasn't landed yet, so Lane B
+// carries the slice it needs. facade pools are the CITY_SPEC table. Lane F reconciles.) ──
+export const SHOP_REGISTRY = {
+ record: { facades: ['timber-teal', 'arcade-tile', 'djsim-record'], signBg: '#1f2e2b', signFg: '#e8d9a0' },
+ opshop: { facades: ['weatherboard', 'fibro-blue', 'djsim-opshop'], signBg: '#2c2820', signFg: '#f0e2c0' },
+ toy: { facades: ['stucco-pink', 'deco-pastel'], signBg: '#7a1f5a', signFg: '#ffe6f2' },
+ book: { facades: ['federation', 'sandstone'], signBg: '#2a231a', signFg: '#f3e6c4' },
+ video: { facades: ['stripmall', 'djsim-video'], signBg: '#141a2c', signFg: '#8fb7ff' },
+ pawn: { facades: ['besser', 'grimy', 'djsim-pawn'], signBg: '#f5c400', signFg: '#c1121f' },
+ milkbar: { facades: ['djsim-milkbar', 'brickveneer'], signBg: '#b21f1f', signFg: '#fff3d6' },
+ dept: { facades: ['terrazzo', 'djsim-dept'], signBg: '#20313a', signFg: '#ffd75e' },
+ stall: { facades: ['market'], signBg: '#2c2820', signFg: '#f0e2c0' },
+};
+export const SHOP_TYPES = Object.keys(SHOP_REGISTRY);
+
+// Parody-name word banks (kept small; deterministic pick per shop). 90s-AU flavour.
+const NAME_BANK = {
+ record: { a: ['Groove', 'Wax', 'Vinyl', 'Spin', 'Needle', 'Rotary'], b: ['Junction', 'Merchants', 'Traders', 'Exchange', 'Bin'] },
+ opshop: { a: ['Vinnies', 'Salvos', 'Second', 'Thrifty', 'Endeavour', 'Lifeline'], b: ['Chance', 'Op Shop', 'Finds', 'Bazaar', 'Nook'] },
+ toy: { a: ['Toyworld', 'Kiddie', 'Wonder', 'Bright', 'Jolly'], b: ['Toys', 'Playtime', 'Corner', 'Emporium'] },
+ book: { a: ['Dog-Eared', 'Second Story', 'Turning', 'Foxed', 'Marginalia'], b: ['Books', 'Book Barn', 'Reads', 'Pages'] },
+ video: { a: ['Blockheads', 'Video', 'Reel', 'Nightowl', 'Civic'], b: ['Video', 'Rental', 'Ezy Hire', 'Movies'] },
+ pawn: { a: ['Cash', 'Quick', 'Second Chance', 'City', 'Honest'], b: ['Converters', 'Pawn', 'Loans', 'Traders'] },
+ milkbar: { a: ['Corner', 'Lucky', 'Rio', 'Astoria', 'Sunny'], b: ['Milk Bar', 'Deli', 'Cafe', 'Store'] },
+ dept: { a: ['Coles', 'Waltons', 'Grace Bros', 'Fossey’s', 'Venture'], b: ['Variety', 'Department', 'Emporium', ''] },
+ stall: { a: ['Trash & Treasure', 'Sunday', 'Boot', 'Market'], b: ['Stall', 'Table', 'Trestle'] },
+};
+
+function makeName(seed, type) {
+ const r = rng(seed, 'name', 0);
+ const bank = NAME_BANK[type] || NAME_BANK.opshop;
+ const a = pick(r, bank.a), b = pick(r, bank.b);
+ return b ? `${a} ${b}`.trim() : a;
+}
+
+// Deterministic hours: most open 9–5-ish, some late, a few closed-feeling.
+function makeHours(seed) {
+ const r = rng(seed, 'hours', 0);
+ const open = irange(r, 7, 10);
+ const close = irange(r, 16, 21);
+ return [open, close];
+}
+
+// Facade skin for a shop, seeded from its type pool.
+function pickFacade(seed, type) {
+ const r = rng(seed, 'facade', 0);
+ return pick(r, (SHOP_REGISTRY[type] || SHOP_REGISTRY.opshop).facades);
+}
+
+// ── Geometry constants for the fixture town ──
+const W = 8; // terraced shop frontage width (m)
+const D = 8; // shop depth (m)
+const N_FRONT = 11; // north-side frontage plane Z (fronts face −Z)
+const S_FRONT = -11; // south-side frontage plane Z (fronts face +Z)
+const CROSS_W = -11; // cross-street west frontage plane X (fronts face +X)
+const CROSS_E = 11; // cross-street east frontage plane X (fronts face −X)
+
+// The terraced blocks. Four along the north frontage + two on the south near the plaza, spread so
+// the main street spans ~6 chunks (streaming visibly kicks in walking end-to-end). Every archetype
+// shows up. 8m shops; the plaza gap sits around the origin.
+const BLOCKS = [
+ { key: 'NW2', side: 'N', xs: [-140, -132, -124, -116], types: ['record', 'opshop', 'book', 'milkbar'] },
+ { key: 'NW1', side: 'N', xs: [-52, -44, -36, -28], types: ['video', 'toy', 'pawn', 'record'] },
+ { key: 'NE1', side: 'N', xs: [28, 36, 44, 52], types: ['opshop', 'book', 'milkbar', 'video'] },
+ { key: 'NE2', side: 'N', xs: [116, 124, 132, 140], types: ['dept', 'toy', 'pawn', 'opshop'] },
+ { key: 'SW', side: 'S', xs: [-52, -44, -36, -28], types: ['milkbar', 'record', 'opshop', 'book'] },
+ { key: 'SE', side: 'S', xs: [28, 36, 44, 52], types: ['toy', 'video', 'pawn', 'record'] },
+];
+
+let _uid = 0;
+const uid = (p) => `${p}${_uid++}`;
+
+function buildShopLots(citySeed, lots, shops, blocks) {
+ _uid = 0;
+ for (const bd of BLOCKS) {
+ const north = bd.side === 'N';
+ const z = north ? N_FRONT + D / 2 : S_FRONT - D / 2;
+ const ry = north ? Math.PI : 0;
+ const frontEdge = north ? 'S' : 'N';
+ const xs = bd.xs, types = bd.types;
+ const minX = Math.min(...xs) - W / 2, maxX = Math.max(...xs) + W / 2;
+ const zNear = north ? N_FRONT : S_FRONT;
+ const zFar = north ? N_FRONT + D : S_FRONT - D;
+ const blockId = `block-${bd.key}`;
+ blocks.push({
+ id: blockId, district: 'main',
+ poly: [[minX, Math.min(zNear, zFar)], [maxX, Math.min(zNear, zFar)],
+ [maxX, Math.max(zNear, zFar)], [minX, Math.max(zNear, zFar)]],
+ });
+ xs.forEach((x, i) => {
+ const type = types[i % types.length];
+ const lotId = uid('lot-');
+ const isAnchor = type === 'dept';
+ const storeys = isAnchor ? 3 : (i % 3 === 0 ? 2 : 1);
+ lots.push({
+ id: lotId, block: blockId, x, z, w: W, d: D, ry,
+ frontEdge, use: isAnchor ? 'anchor' : 'shop',
+ });
+ const seed = citySeed + _uid * 2654435761 % 0xffffffff;
+ const s = (seed >>> 0);
+ shops.push({
+ id: uid('shop-'), lot: lotId, type,
+ name: makeName(s, type), sign: makeName(s, type),
+ seed: s, facadeSkin: pickFacade(s, type), storeys, hours: makeHours(s),
+ });
+ });
+ }
+}
+
+function buildHouses(citySeed, lots) {
+ // Backstreets: houses lining the cross street north of the square.
+ const specs = [
+ { x: CROSS_W - 5, z: 36, ry: Math.PI / 2, frontEdge: 'E' },
+ { x: CROSS_W - 5, z: 60, ry: Math.PI / 2, frontEdge: 'E' },
+ { x: CROSS_E + 5, z: 48, ry: -Math.PI / 2, frontEdge: 'W' },
+ { x: CROSS_E + 5, z: 72, ry: -Math.PI / 2, frontEdge: 'W' },
+ ];
+ specs.forEach((sp, i) => {
+ lots.push({
+ id: `lot-house-${i}`, block: 'block-back', x: sp.x, z: sp.z,
+ w: 9, d: 10, ry: sp.ry, frontEdge: sp.frontEdge, use: 'house',
+ });
+ });
+}
+
+function buildStalls(citySeed, lots, shops) {
+ // Two market stalls in the square (brickpave plaza around the origin).
+ const specs = [{ x: -9, z: 6, ry: 0 }, { x: 9, z: -6, ry: Math.PI }];
+ specs.forEach((sp, i) => {
+ const lotId = `lot-stall-${i}`;
+ lots.push({ id: lotId, block: 'block-square', x: sp.x, z: sp.z, w: 4, d: 3, ry: sp.ry, use: 'stall', frontEdge: 'S' });
+ const s = (citySeed + 7777 + i * 131) >>> 0;
+ shops.push({
+ id: `shop-stall-${i}`, lot: lotId, type: 'stall',
+ name: makeName(s, 'stall'), sign: makeName(s, 'stall'),
+ seed: s, facadeSkin: 'market', storeys: 1, hours: [8, 14],
+ });
+ });
+}
+
+/**
+ * fixturePlan(citySeed) → CityPlan (schema v1). Deterministic in citySeed.
+ */
+export function fixturePlan(citySeed = 20261990) {
+ citySeed = citySeed >>> 0;
+ const lots = [], shops = [], blocks = [];
+ buildShopLots(citySeed, lots, shops, blocks);
+ buildHouses(citySeed, lots);
+ buildStalls(citySeed, lots, shops);
+
+ const r = rng(citySeed, 'city', 0);
+
+ const plan = {
+ version: 1,
+ citySeed,
+ name: pick(r, ['Wollamby', 'Dunroamin', 'Barretta', 'Coolabah', 'Mungindi', 'Yarraville Junction']),
+ size: { w: 1024, d: 1024 },
+ districts: [
+ { id: 'main', kind: 'mainstreet', cx: 0, cz: 0 },
+ { id: 'square', kind: 'market', cx: 0, cz: 0 },
+ { id: 'back', kind: 'backstreets', cx: 0, cz: 55 },
+ ],
+ streets: {
+ nodes: [
+ { id: 'n0', x: -155, z: 0 }, // west end, main
+ { id: 'n1', x: 0, z: 0 }, // central intersection / square
+ { id: 'n2', x: 155, z: 0 }, // east end, main
+ { id: 'n3', x: 0, z: 95 }, // north end, cross street
+ ],
+ edges: [
+ { id: 'e0', a: 'n0', b: 'n1', width: 14, kind: 'main' },
+ { id: 'e1', a: 'n1', b: 'n2', width: 14, kind: 'main' },
+ { id: 'e2', a: 'n1', b: 'n3', width: 8, kind: 'side' },
+ ],
+ },
+ blocks,
+ lots,
+ shops,
+ // Which sky this town gets (Lane B lighting reads this; seeded so a town's weather is stable).
+ sky: pick(r, ['golden-arvo', 'spring-puffy', 'endless-blue', 'high-cirrus', 'summer-storm', 'outback-dusk']),
+ };
+ return plan;
+}
+
+// Default fixture used by index.html when citygen is absent.
+export default fixturePlan;
diff --git a/web/js/world/furniture.js b/web/js/world/furniture.js
new file mode 100644
index 0000000..7f77b65
--- /dev/null
+++ b/web/js/world/furniture.js
@@ -0,0 +1,192 @@
+// PROCITY Lane B — furniture.js
+// Instanced street furniture along the footpaths: streetlights (emissive lamp at night), benches,
+// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
+// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
+//
+// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs.
+// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
+// left OFF for v1 so async loads can never write into an unloaded chunk.
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { rng, frange } from '../core/prng.js';
+import { chunkOf, resolveEdges } from './planutil.js';
+
+const FOOT = 3.5;
+
+// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
+let _tpl = null;
+function templates() {
+ if (_tpl) return _tpl;
+ const box = (w, h, d, x = 0, y = 0, z = 0) => {
+ const g = new THREE.BoxGeometry(w, h, d); g.translate(x, y, z); return g;
+ };
+ // streetlight pole + arm (dark). Lamp head is separate (emissive).
+ const pole = mergeGeometries([
+ box(0.12, 4.2, 0.12, 0, 2.1, 0),
+ box(0.09, 0.09, 1.1, 0, 4.1, 0.55),
+ ], false);
+ const lamp = box(0.34, 0.18, 0.34, 0, 4.05, 1.05);
+ // bench: seat + back + two legs
+ const bench = mergeGeometries([
+ box(1.6, 0.08, 0.45, 0, 0.45, 0),
+ box(1.6, 0.5, 0.06, 0, 0.72, -0.2),
+ box(0.08, 0.45, 0.4, -0.7, 0.22, 0),
+ box(0.08, 0.45, 0.4, 0.7, 0.22, 0),
+ ], false);
+ // bin: short cylinder
+ const bin = new THREE.CylinderGeometry(0.28, 0.24, 0.9, 10); bin.translate(0, 0.45, 0);
+ // bus stop shelter: two posts + flat roof + a small bench
+ const busstop = mergeGeometries([
+ box(0.1, 2.3, 0.1, -1.3, 1.15, -0.5), box(0.1, 2.3, 0.1, 1.3, 1.15, -0.5),
+ box(3.0, 0.12, 1.4, 0, 2.35, 0),
+ box(2.4, 0.08, 0.4, 0, 0.5, -0.4),
+ ], false);
+ // gum tree: crossed alpha-textured planes
+ const treeGeo = mergeGeometries([
+ new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
+ new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
+ ], false);
+ _tpl = { pole, lamp, bench, bin, busstop, treeGeo };
+ return _tpl;
+}
+
+// tree billboard texture (canvas — no font files, house law)
+let _treeTex = null;
+function treeTexture() {
+ if (_treeTex) return _treeTex;
+ const c = document.createElement('canvas'); c.width = c.height = 128;
+ const x = c.getContext('2d');
+ x.clearRect(0, 0, 128, 128);
+ x.fillStyle = '#6a5a44'; x.fillRect(58, 74, 12, 54); // trunk
+ const blobs = [[64, 44, 34], [44, 56, 24], [84, 56, 24], [64, 30, 22]];
+ for (const [bx, by, br] of blobs) {
+ x.fillStyle = ['#5c7a3e', '#6b8a48', '#4f6d38'][(bx + by) % 3];
+ x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
+ }
+ _treeTex = new THREE.CanvasTexture(c); _treeTex.colorSpace = THREE.SRGBColorSpace;
+ return _treeTex;
+}
+
+// ── shared materials (module-level; not disposed per chunk) ──
+let _mats = null;
+function materials() {
+ if (_mats) return _mats;
+ _mats = {
+ dark: new THREE.MeshStandardMaterial({ color: 0x33322f, roughness: 0.7, metalness: 0.2 }),
+ lamp: new THREE.MeshStandardMaterial({ color: 0x2a2a26, roughness: 0.5, emissive: new THREE.Color(0xffd9a0), emissiveIntensity: 0 }),
+ wood: new THREE.MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.8 }),
+ bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
+ shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
+ tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
+ };
+ return _mats;
+}
+
+const _m = new THREE.Matrix4();
+const _p = new THREE.Vector3();
+const _q = new THREE.Quaternion();
+const _s = new THREE.Vector3(1, 1, 1);
+const _up = new THREE.Vector3(0, 1, 0);
+
+/**
+ * buildChunkFurniture(chunkData, ctx, cx, cz) → { group, applyNight, dispose }
+ * ctx: { plan, citySeed, night }
+ * Places furniture along every street edge, keeping only items whose position lands in this chunk.
+ */
+export function buildChunkFurniture(chunkData, ctx, cx, cz) {
+ const group = new THREE.Group();
+ group.name = 'chunk-furniture';
+ const t = templates(), mat = materials();
+ const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
+
+ const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [] };
+ const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
+
+ const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
+ _p.set(x, y, z); _q.setFromAxisAngle(_up, yaw); _s.set(sx, sy, sz);
+ list.push(_m.clone().compose(_p, _q, _s));
+ };
+
+ for (const e of edges) {
+ const dx = e.bx - e.ax, dz = e.bz - e.az;
+ const len = Math.hypot(dx, dz) || 1;
+ const ux = dx / len, uz = dz / len; // along
+ const px = -uz, pz = ux; // perp (unit)
+ const yaw = Math.atan2(ux, uz); // face along +Z→dir
+ const r = rng(ctx.citySeed, 'furn', hashEdge(e.id));
+ const halfRoad = e.width / 2;
+
+ // streetlights every ~18m, alternating sides, near the kerb
+ let side = 1;
+ for (let s = 8; s < len - 4; s += 18) {
+ const off = (halfRoad + 0.7) * side;
+ const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
+ if (here(x, z)) {
+ const armYaw = side > 0 ? yaw + Math.PI : yaw; // arm reaches over the road
+ pushYaw(lists.pole, x, 0, z, armYaw);
+ pushYaw(lists.lamp, x, 0, z, armYaw);
+ }
+ side = -side;
+ }
+ // benches + bins every ~26m on the building side of the footpath, facing the road
+ for (let s = 14; s < len - 6; s += 26) {
+ const bside = (Math.floor(s / 26) % 2) ? 1 : -1;
+ const off = (halfRoad + FOOT - 0.8) * bside;
+ const bx = e.ax + ux * s + px * off, bz = e.az + uz * s + pz * off;
+ if (here(bx, bz)) pushYaw(lists.bench, bx, 0, bz, yaw + (bside > 0 ? Math.PI : 0));
+ const nx = bx + ux * 1.4, nz = bz + uz * 1.4;
+ if (here(nx, nz)) pushYaw(lists.bin, nx, 0, nz, 0);
+ }
+ // gum trees only along side/lane verges (main street has no verge room)
+ if (e.kind === 'side' || e.kind === 'lane') {
+ for (let s = 6; s < len - 4; s += 22) {
+ for (const vs of [1, -1]) {
+ const off = (halfRoad + FOOT + 1.2) * vs;
+ const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
+ if (here(x, z)) pushYaw(lists.tree, x, 0, z, frange(r, 0, Math.PI), 1, 1 + r() * 0.3, 1);
+ }
+ }
+ }
+ // one bus stop near each main-street edge's near end
+ if (e.kind === 'main') {
+ const s = 10, off = (halfRoad + FOOT - 0.9);
+ const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
+ if (here(x, z)) pushYaw(lists.busstop, x, 0, z, yaw + Math.PI);
+ }
+ }
+
+ const ims = [];
+ const emit = (geo, material, list, cast = true) => {
+ if (!list.length) return;
+ const im = new THREE.InstancedMesh(geo, material, list.length);
+ for (let i = 0; i < list.length; i++) im.setMatrixAt(i, list[i]);
+ im.instanceMatrix.needsUpdate = true;
+ im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
+ group.add(im); ims.push(im);
+ };
+ emit(t.pole, mat.dark, lists.pole);
+ emit(t.lamp, mat.lamp, lists.lamp, false);
+ emit(t.bench, mat.wood, lists.bench);
+ emit(t.bin, mat.bin, lists.bin);
+ emit(t.busstop, mat.shelter, lists.busstop);
+ emit(t.treeGeo, mat.tree, lists.tree, false);
+
+ function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
+ applyNight(!!ctx.night);
+
+ function dispose() {
+ for (const im of ims) { im.dispose && im.dispose(); group.remove(im); }
+ // shared templates/materials intentionally NOT disposed (reused by other chunks)
+ group.clear();
+ }
+ return { group, applyNight, dispose };
+}
+
+function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
+
+// ── OPTIONAL 3GOD-depot upgrade (documented, off for v1) ──────────────────────────────────────
+// import { loadGLB } from '../core/loaders.js';
+// To swap a primitive for a real GLB prop: loadGLB('depot:bench.glb').then(g => { if(!g) return;
+// ... build an InstancedMesh from g.scene's merged geometry and replace the primitive one ... });
+// Guard against the chunk having been disposed before the promise resolves (check a live flag).
diff --git a/web/js/world/ground.js b/web/js/world/ground.js
new file mode 100644
index 0000000..fee7daf
--- /dev/null
+++ b/web/js/world/ground.js
@@ -0,0 +1,104 @@
+// PROCITY Lane B — ground.js
+// Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips
+// per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged
+// flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam.
+// UV tiling is baked into the geometry so one shared ground material serves strips of any length.
+// (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.)
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { resolveEdges } from './planutil.js';
+
+const FOOT = 3.5; // footpath width
+const TILE = 5; // metres per texture tile
+const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
+
+// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
+// UVs baked to tile every TILE metres.
+function hQuad(cx, cz, dir, perp, len, wid, y) {
+ const hl = len / 2, hw = wid / 2;
+ const ax = dir.x * hl, az = dir.z * hl; // along
+ const bx = perp.x * hw, bz = perp.z * hw; // across
+ const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz];
+ const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1);
+ const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE);
+ const g = new THREE.BufferGeometry();
+ // winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible.
+ g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
+ ...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3));
+ g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
+ 0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2));
+ g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([
+ 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
+ return g;
+}
+
+// Axis-aligned rectangle helper (plaza, base plane).
+function rectGeo(cx, cz, w, d, y, tile = TILE) {
+ return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y);
+}
+
+export function buildGround(plan, skins) {
+ const group = new THREE.Group();
+ group.name = 'ground';
+ const edges = resolveEdges(plan);
+
+ const roadGeos = [], footGeos = [], kerbGeos = [];
+ const dir = new THREE.Vector3(), perp = new THREE.Vector3();
+
+ for (const e of edges) {
+ const dx = e.bx - e.ax, dz = e.bz - e.az;
+ const len = Math.hypot(dx, dz) || 1;
+ dir.set(dx / len, 0, dz / len);
+ perp.set(-dir.z, 0, dir.x);
+ const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
+ const roadLen = len + OVERLAP * 2;
+ roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
+ // footpaths flank the road
+ const off = e.width / 2 + FOOT / 2;
+ footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
+ footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
+ // low kerbs between road and footpath
+ const koff = e.width / 2 + 0.12;
+ kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
+ kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
+ }
+
+ // market plaza (brickpave) over the central square
+ const sq = (plan.districts || []).find((d) => d.kind === 'market');
+ const plazaGeos = [];
+ if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015));
+
+ // base ground plane under the whole town (grass/dirt), sized to the plan extent
+ let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
+ for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
+ for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
+ const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120;
+ const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2;
+ const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03);
+
+ const meshes = [];
+ const add = (geos, material, receive = true) => {
+ if (!geos.length) return;
+ const merged = mergeGeometries(geos, false);
+ geos.forEach((g) => g.dispose());
+ const m = new THREE.Mesh(merged, material);
+ m.receiveShadow = receive; m.castShadow = false;
+ group.add(m); meshes.push(m);
+ };
+
+ const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave';
+ add([baseGeo], skins.groundMat(grassSkin));
+ add(roadGeos, skins.groundMat(roadSkin));
+ add(footGeos, skins.groundMat(footSkin));
+ add(plazaGeos, skins.groundMat(plazaSkin));
+ const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 });
+ add(kerbGeos, kerbMat);
+
+ function dispose() {
+ for (const m of meshes) m.geometry.dispose();
+ kerbMat.dispose();
+ group.clear();
+ }
+ return { group, dispose };
+}
diff --git a/web/js/world/hud.js b/web/js/world/hud.js
new file mode 100644
index 0000000..2136d40
--- /dev/null
+++ b/web/js/world/hud.js
@@ -0,0 +1,120 @@
+// PROCITY Lane B — hud.js
+// The street HUD: crosshair, shop-name tooltip (throttled centre-screen raycast, far = 6m),
+// time-of-day + seed + live perf readout (renderer.info: draw calls & triangles), and the
+// door-click → `procity:enterShop` hand-off. Creates its own DOM overlay so index.html stays thin.
+
+import * as THREE from 'three';
+
+const CSS = `
+#pc-hud{position:fixed;inset:0;pointer-events:none;font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;z-index:10}
+#pc-cross{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#fff;opacity:.7;box-shadow:0 0 3px #000}
+#pc-tip{position:absolute;left:50%;top:calc(50% + 22px);transform:translateX(-50%);background:rgba(20,16,10,.82);padding:6px 12px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;text-shadow:0 1px 2px #000}
+#pc-dbg{position:absolute;right:10px;top:10px;text-align:right;background:rgba(20,16,10,.55);padding:8px 11px;border-radius:8px;min-width:150px}
+#pc-dbg b{color:#ffd75e;font-weight:600}
+#pc-dbg .warn{color:#ff9a6a}
+#pc-clock{font-size:15px;margin-bottom:3px}
+#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
+#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
+`;
+
+export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
+ if (!document.getElementById('pc-hud-style')) {
+ const st = document.createElement('style'); st.id = 'pc-hud-style'; st.textContent = CSS;
+ document.head.appendChild(st);
+ }
+ const root = document.createElement('div'); root.id = 'pc-hud';
+ root.innerHTML = `
+
+
+
+
—
+
town —
+
seed —
+
fps —
+
draws — · tris —
+
chunks —
+
+
+
WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release
`;
+ document.body.appendChild(root);
+ const $ = (id) => root.querySelector('#' + id);
+ const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
+ elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
+ $('pc-town').textContent = plan.name || '—';
+ $('pc-seed').textContent = plan.citySeed;
+
+ const ray = new THREE.Raycaster();
+ ray.far = 6;
+ const centre = new THREE.Vector2(0, 0);
+ let hovered = null; // { shopId, name }
+ let frame = 0, fpsAcc = 0, fpsCount = 0, fpsShown = 0;
+
+ function nearestDoor(rects, point) {
+ let best = null, bd = Infinity;
+ for (const r of rects) {
+ const d = (r.x - point.x) ** 2 + (r.z - point.z) ** 2;
+ if (d < bd) { bd = d; best = r; }
+ }
+ return best;
+ }
+
+ function raycastDoor() {
+ const meshes = getDoorMeshes ? getDoorMeshes() : [];
+ if (!meshes.length) { hovered = null; return; }
+ ray.setFromCamera(centre, camera);
+ const hits = ray.intersectObjects(meshes, false);
+ if (hits.length) {
+ const h = hits[0];
+ const rects = h.object.userData.doorRects || [];
+ const door = nearestDoor(rects, h.point);
+ hovered = door ? { shopId: door.shopId, name: door.name } : null;
+ } else {
+ hovered = null;
+ }
+ }
+
+ function update(dt, info) {
+ // fps (smoothed over ~0.4s)
+ fpsAcc += dt; fpsCount++;
+ if (fpsAcc >= 0.4) { fpsShown = Math.round(fpsCount / fpsAcc); fpsAcc = 0; fpsCount = 0; }
+
+ frame++;
+ if (frame % 6 === 0) raycastDoor();
+
+ // tooltip
+ if (hovered) { elTip.textContent = `🚪 ${hovered.name} — click to enter`; elTip.style.opacity = 1; }
+ else elTip.style.opacity = 0;
+
+ // debug readout
+ if (info) {
+ if (info.clock) elClock.textContent = `${info.clock.label} ${info.clock.hour}${info.clock.night ? ' 🌙' : ''}`;
+ elFps.textContent = fpsShown;
+ const draws = renderer.info.render.calls, tris = renderer.info.render.triangles;
+ elDraws.innerHTML = draws > 300 ? `${draws}` : draws;
+ elTris.innerHTML = tris > 200000 ? `${(tris / 1000 | 0)}k` : `${(tris / 1000 | 0)}k`;
+ if (info.chunks != null) elChunks.textContent = info.chunks;
+ }
+ }
+
+ let toastT = 0;
+ function showToast(msg, secs = 2.6) {
+ elToast.textContent = msg; elToast.style.opacity = 1; toastT = secs;
+ }
+ function tickToast(dt) { if (toastT > 0) { toastT -= dt; if (toastT <= 0) elToast.style.opacity = 0; } }
+
+ // door click → enter shop (only when hovering one)
+ function onClick() {
+ if (hovered && onEnterShop) onEnterShop(hovered.shopId, hovered.name);
+ }
+ renderer.domElement.addEventListener('click', onClick);
+
+ function setVisible(v) { root.style.display = v ? 'block' : 'none'; }
+
+ function dispose() {
+ renderer.domElement.removeEventListener('click', onClick);
+ root.remove();
+ }
+
+ return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
+ getFps: () => fpsShown };
+}
diff --git a/web/js/world/lighting.js b/web/js/world/lighting.js
new file mode 100644
index 0000000..036e4ba
--- /dev/null
+++ b/web/js/world/lighting.js
@@ -0,0 +1,113 @@
+// PROCITY Lane B — lighting.js
+// Sky dome (seeded sky-*.jpg on a BackSide sphere) + a 6-segment day/night cycle ported from
+// 90sDJsim's applyLighting: one hemisphere ambient + one directional sun (single shadow map that
+// follows the player) + a night flag that drives window/lamp emissives (via a callback into the
+// ChunkManager). Linear fog tuned to the streaming radius doubles as the pop-in mask.
+//
+// Global, not chunked. The sky dome and sun re-centre on the player each frame so the world
+// never runs out from under you.
+
+import * as THREE from 'three';
+
+// [name, sunI, sunColor, hemiSky, hemiGround, hemiI, skyTint, fogColor, exposure, night, sunOffset]
+const SEG = [
+ ['DAWN', 0.75, 0xffd9b0, 0xbcd0e8, 0x8a7a60, 0.75, 0xdcdce8, 0xcfc6ba, 1.05, false, [80, 26, 14]],
+ ['MORNING', 1.20, 0xffe8c8, 0xcfe0f2, 0x9a8a6c, 0.95, 0xffffff, 0xd6d6cc, 1.00, false, [50, 46, 22]],
+ ['MIDDAY', 1.55, 0xffffff, 0xd8e8ff, 0xa89878, 1.10, 0xffffff, 0xe0e2da, 0.98, false, [12, 72, 12]],
+ ['ARVO', 1.15, 0xffdca0, 0xc8d4e8, 0xa08858, 1.00, 0xf2e8d8, 0xe0cdb0, 1.02, false, [-42, 46, 20]],
+ ['DUSK', 0.55, 0xff9860, 0x9a86b0, 0x6a4a3a, 0.72, 0xb89a8a, 0xa07860, 1.10, false, [-74, 16, 10]],
+ ['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
+];
+const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00'];
+
+export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
+ const group = new THREE.Group();
+ group.name = 'lighting';
+ scene.add(group);
+
+ // sky dome — seeded photo skin, darkened at night via material colour
+ const skyName = plan.sky || 'golden-arvo';
+ const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
+ skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
+ const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
+ group.add(dome);
+
+ const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
+ group.add(hemi);
+ const sun = new THREE.DirectionalLight(0xffffff, 1.4);
+ sun.castShadow = shadows; // dense cities skip the shadow pass (≈halves draw calls; doc allows "none")
+ sun.shadow.mapSize.set(2048, 2048);
+ sun.shadow.bias = -0.0004;
+ const SH = 60; // half-extent of the shadow frustum around the player
+ Object.assign(sun.shadow.camera, { left: -SH, right: SH, top: SH, bottom: -SH, near: 1, far: 260 });
+ sun.shadow.camera.updateProjectionMatrix();
+ group.add(sun, sun.target);
+
+ // fog fades distant chunks out just before the streaming radius, hiding pop-in
+ const fog = new THREE.Fog(0xd6d6cc, 40, (radius + 0.4) * 64);
+ scene.fog = fog;
+
+ const _skyTint = new THREE.Color();
+ const _fogTint = new THREE.Color();
+ let seg = 2, night = false;
+ let clock = seg + 0.001, paused = false;
+ const DAY_SECONDS = 240; // one full cycle
+ let onNightChange = null;
+ let renderer = null;
+
+ function apply(i) {
+ const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
+ sun.intensity = sunI; sun.color.set(sunC);
+ hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
+ _skyTint.set(skyTint); skyMat.color.copy(_skyTint);
+ _fogTint.set(fogC); fog.color.copy(_fogTint);
+ scene.background = _fogTint.clone(); // horizon colour behind the (finite) dome
+ if (renderer) renderer.toneMappingExposure = exposure;
+ const was = night; night = isNight; seg = i;
+ if (isNight !== was && onNightChange) onNightChange(isNight);
+ }
+
+ function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
+ function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); }
+
+ const _sunOff = new THREE.Vector3();
+ function update(dt, playerPos) {
+ if (!paused) {
+ clock = (clock + (dt / DAY_SECONDS) * SEG.length) % SEG.length;
+ const i = Math.floor(clock);
+ if (i !== seg) apply(i);
+ }
+ // re-centre dome + sun on the player
+ dome.position.copy(playerPos);
+ const off = SEG[seg][10];
+ _sunOff.set(off[0], off[1], off[2]).multiplyScalar(0.8);
+ sun.position.copy(playerPos).add(_sunOff);
+ sun.target.position.copy(playerPos);
+ sun.target.updateMatrixWorld();
+ }
+
+ function getClock() {
+ const frac = clock - Math.floor(clock);
+ return { seg, label: SEG[seg][0], hour: HOUR[seg], night, frac };
+ }
+
+ function attachRenderer(r) { renderer = r; r.toneMappingExposure = SEG[seg][8]; }
+
+ function dispose() {
+ scene.fog = null;
+ dome.geometry.dispose(); skyMat.dispose();
+ scene.remove(group);
+ }
+
+ apply(seg);
+ return {
+ group, sun, hemi,
+ update, setSegment, stepSegment, getClock, attachRenderer, dispose,
+ setPaused: (p) => { paused = p; },
+ togglePaused: () => (paused = !paused),
+ isNight: () => night,
+ get segCount() { return SEG.length; },
+ set onNightChange(fn) { onNightChange = fn; },
+ get onNightChange() { return onNightChange; },
+ };
+}
diff --git a/web/js/world/minimap.js b/web/js/world/minimap.js
new file mode 100644
index 0000000..f510ff0
--- /dev/null
+++ b/web/js/world/minimap.js
@@ -0,0 +1,82 @@
+// PROCITY Lane B — minimap.js
+// The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by
+// shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's
+// map.html debug view grows into the richer directory later (Lane F reconciles).
+
+const TYPE_COLOR = {
+ record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0',
+ pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a',
+ house: '#6b5a48', anchor: '#5a6a7a',
+};
+
+export function createMinimap(plan) {
+ const wrap = document.createElement('div');
+ wrap.id = 'pc-map';
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;'
+ + 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0';
+ const canvas = document.createElement('canvas');
+ canvas.width = 720; canvas.height = 720;
+ canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000';
+ const cap = document.createElement('div');
+ cap.style.cssText = 'margin-top:12px;opacity:.8';
+ cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`;
+ wrap.append(canvas, cap);
+ document.body.appendChild(wrap);
+ const ctx = canvas.getContext('2d');
+
+ // world→canvas transform (fit plan extent with margin)
+ let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
+ for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
+ for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
+ const pad = 24, W = canvas.width, H = canvas.height;
+ const span = Math.max(maxX - minX, maxZ - minZ) + 40;
+ const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2;
+ const sc = (W - pad * 2) / span;
+ const tx = (x) => W / 2 + (x - cxw) * sc;
+ const tz = (z) => H / 2 + (z - czw) * sc;
+
+ const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
+ const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
+
+ function drawStatic() {
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H);
+ // streets
+ for (const e of plan.streets.edges) {
+ const a = nodes.get(e.a), b = nodes.get(e.b);
+ ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc);
+ ctx.lineCap = 'round';
+ ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke();
+ }
+ // lots
+ for (const l of plan.lots) {
+ const s = shopByLot.get(l.id);
+ const col = TYPE_COLOR[s ? s.type : l.use] || '#666';
+ ctx.save();
+ ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0));
+ ctx.fillStyle = col;
+ ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc);
+ // front-edge tick (toward the street)
+ ctx.fillStyle = 'rgba(255,255,255,.5)';
+ ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2);
+ ctx.restore();
+ }
+ }
+
+ function draw(playerPos, fwd) {
+ drawStatic();
+ const x = tx(playerPos.x), z = tz(playerPos.z);
+ // heading (world +X→right, +Z→down on the map)
+ ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3;
+ ctx.beginPath(); ctx.moveTo(x, z);
+ ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke();
+ ctx.fillStyle = '#ffd75e';
+ ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill();
+ }
+
+ return {
+ draw,
+ setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
+ dispose: () => wrap.remove(),
+ };
+}
diff --git a/web/js/world/planutil.js b/web/js/world/planutil.js
new file mode 100644
index 0000000..16e4e31
--- /dev/null
+++ b/web/js/world/planutil.js
@@ -0,0 +1,116 @@
+// PROCITY Lane B — planutil.js
+// Pure plan/geometry helpers over a CityPlan (no THREE). Chunk math (CITY_SPEC: 64m cells),
+// node/lot/shop indices, edge resolution, and the oriented-rectangle collider for a lot.
+// If Lane A later ships an official chunkIndex(plan) we swap the import; the shapes match.
+
+export const CHUNK = 64; // metres (CITY_SPEC law)
+
+export const chunkCoord = (v) => Math.floor(v / CHUNK);
+export const chunkKey = (cx, cz) => `${cx},${cz}`;
+export const chunkOf = (x, z) => ({ cx: chunkCoord(x), cz: chunkCoord(z) });
+export const parseKey = (k) => { const [cx, cz] = k.split(',').map(Number); return { cx, cz }; };
+
+// Fast lookup maps built once per plan.
+export function indexPlan(plan) {
+ const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
+ const lots = new Map(plan.lots.map((l) => [l.id, l]));
+ const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
+ const shopsById = new Map(plan.shops.map((s) => [s.id, s]));
+ return { nodes, lots, shopByLot, shopsById };
+}
+
+// Resolve street edges to world segments with widths.
+export function resolveEdges(plan) {
+ const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
+ return plan.streets.edges.map((e) => {
+ const a = nodes.get(e.a), b = nodes.get(e.b);
+ return { id: e.id, kind: e.kind, width: e.width, ax: a.x, az: a.z, bx: b.x, bz: b.z };
+ });
+}
+
+/**
+ * chunkIndex(plan) → Map
+ * A lot belongs to the chunk containing its centre. An edge is registered to every chunk its
+ * segment passes through (rasterised at CHUNK/4 steps) so the streamer can draw partial roads
+ * if it ever needs to. Shops ride along with their lot.
+ */
+export function chunkIndex(plan) {
+ const { shopByLot } = indexPlan(plan);
+ const map = new Map();
+ const cell = (cx, cz) => {
+ const k = chunkKey(cx, cz);
+ let c = map.get(k);
+ if (!c) { c = { lots: [], shops: [], edges: [] }; map.set(k, c); }
+ return c;
+ };
+ for (const lot of plan.lots) {
+ const { cx, cz } = chunkOf(lot.x, lot.z);
+ const c = cell(cx, cz);
+ c.lots.push(lot);
+ const s = shopByLot.get(lot.id);
+ if (s) c.shops.push(s);
+ }
+ const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
+ for (const e of plan.streets.edges) {
+ const a = nodes.get(e.a), b = nodes.get(e.b);
+ const dx = b.x - a.x, dz = b.z - a.z;
+ const len = Math.hypot(dx, dz) || 1;
+ const ux = dx / len, uz = dz / len, px = -uz, pz = ux; // along + perpendicular units
+ // Register every chunk the road BAND touches — not just the centreline — so furniture placed
+ // at a perpendicular offset (footpath/verge) can never fall in an unbuilt chunk and vanish.
+ const band = e.width / 2 + 10;
+ const along = Math.max(1, Math.ceil(len / (CHUNK / 4)));
+ const across = Math.max(1, Math.ceil((band * 2) / (CHUNK / 4)));
+ const seen = new Set();
+ for (let i = 0; i <= along; i++) {
+ const sx = a.x + dx * (i / along), sz = a.z + dz * (i / along);
+ for (let j = 0; j <= across; j++) {
+ const o = -band + (2 * band) * (j / across);
+ const { cx, cz } = chunkOf(sx + px * o, sz + pz * o);
+ const k = chunkKey(cx, cz);
+ if (!seen.has(k)) { seen.add(k); cell(cx, cz).edges.push(e.id); }
+ }
+ }
+ }
+ return map;
+}
+
+// Oriented-rectangle collider for a lot (local X = frontage w, local Z = depth d, rotated ry).
+// Yards/houses get a fatter keep-out so you can't walk through private front gardens.
+export function lotCollider(lot) {
+ const yard = lot.use === 'house' || lot.use === 'yard';
+ const pad = yard ? 2.5 : 0; // private-yard buffer in front of houses
+ return {
+ x: lot.x, z: lot.z, ry: lot.ry || 0,
+ hw: lot.w / 2, hd: lot.d / 2 + pad / 2,
+ use: lot.use,
+ };
+}
+
+// Test/point-push a circle of radius r at (px,pz) out of an oriented rect collider.
+// Returns [nx, nz] corrected position (or the same point if no overlap). Reused every frame —
+// pass scratch out-array to avoid allocation.
+export function pushOutRect(px, pz, r, col, out) {
+ // Convention must match buildings.toWorld (local→world = RotY(ry)): dx = lx·cos + lz·sin,
+ // dz = −lx·sin + lz·cos. So world→local is the inverse (RotY(−ry)) below, and the push-out
+ // back-transform is RotY(ry). (Earlier this used the wrong sign and only happened to work for
+ // axis-aligned ry ∈ {0,±π/2,π}; arbitrary ry from generatePlan needs the correct inverse.)
+ const cos = Math.cos(col.ry), sin = Math.sin(col.ry);
+ const dx = px - col.x, dz = pz - col.z;
+ const lx = dx * cos - dz * sin; // world→local (RotY(−ry))
+ const lz = dx * sin + dz * cos;
+ const ex = col.hw + r, ez = col.hd + r;
+ if (lx > -ex && lx < ex && lz > -ez && lz < ez) {
+ // overlapping — push along axis of least penetration
+ const penX = ex - Math.abs(lx);
+ const penZ = ez - Math.abs(lz);
+ let nlx = lx, nlz = lz;
+ if (penX < penZ) nlx = lx < 0 ? -ex : ex;
+ else nlz = lz < 0 ? -ez : ez;
+ out[0] = col.x + (nlx * cos + nlz * sin); // local→world (RotY(ry)), inverse of the above
+ out[1] = col.z + (-nlx * sin + nlz * cos);
+ return true;
+ }
+ out[0] = px; out[1] = pz;
+ return false;
+}
diff --git a/web/js/world/player.js b/web/js/world/player.js
new file mode 100644
index 0000000..eb688b2
--- /dev/null
+++ b/web/js/world/player.js
@@ -0,0 +1,82 @@
+// PROCITY Lane B — player.js
+// PointerLockControls first-person walk (WASD + shift-run), eye at 1.7m, with push-out collision
+// against the oriented-rectangle colliders of the nearby chunks (buildings + private yards).
+// No navmesh — the town is rectangles, so a couple of push-out passes is plenty. All scratch
+// vectors are reused; the update loop allocates nothing.
+
+import * as THREE from 'three';
+import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
+import { pushOutRect } from './planutil.js';
+
+const EYE = 1.7;
+const RADIUS = 0.38; // player collision radius
+const WALK = 4.6, RUN = 8.8;
+
+export function createPlayer(camera, domElement, { getColliders }) {
+ camera.position.y = EYE;
+ const controls = new PointerLockControls(camera, domElement);
+
+ const _dir = new THREE.Vector3();
+ const _fwd = new THREE.Vector3();
+ const _right = new THREE.Vector3();
+ const _move = new THREE.Vector3();
+ const _out = [0, 0];
+ let enabled = true;
+
+ // keys: a live Set of held key codes (index.html fills it)
+ function update(dt, keys) {
+ if (!enabled) return;
+ // horizontal forward/right from camera yaw
+ camera.getWorldDirection(_dir);
+ _fwd.set(_dir.x, 0, _dir.z);
+ if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, -1);
+ _fwd.normalize();
+ _right.set(-_fwd.z, 0, _fwd.x); // camera-right = normalize(cross(forward, up)) on XZ
+
+ let f = 0, s = 0;
+ if (keys.has('KeyW') || keys.has('ArrowUp')) f += 1;
+ if (keys.has('KeyS') || keys.has('ArrowDown')) f -= 1;
+ if (keys.has('KeyD') || keys.has('ArrowRight')) s += 1;
+ if (keys.has('KeyA') || keys.has('ArrowLeft')) s -= 1;
+
+ _move.set(0, 0, 0);
+ if (f || s) {
+ _move.addScaledVector(_fwd, f).addScaledVector(_right, s);
+ if (_move.lengthSq() > 1e-6) _move.normalize();
+ const speed = (keys.has('ShiftLeft') || keys.has('ShiftRight')) ? RUN : WALK;
+ _move.multiplyScalar(speed * dt);
+ }
+
+ let px = camera.position.x + _move.x;
+ let pz = camera.position.z + _move.z;
+
+ // collision: two push-out passes so inner corners resolve
+ const cols = getColliders ? getColliders(px, pz) : null;
+ if (cols && cols.length) {
+ for (let pass = 0; pass < 2; pass++) {
+ for (let i = 0; i < cols.length; i++) {
+ if (pushOutRect(px, pz, RADIUS, cols[i], _out)) { px = _out[0]; pz = _out[1]; }
+ }
+ }
+ }
+ camera.position.x = px;
+ camera.position.z = pz;
+ camera.position.y = EYE;
+ }
+
+ function teleport(x, z, yaw = 0) {
+ camera.position.set(x, EYE, z);
+ camera.rotation.set(0, yaw, 0, 'YXZ');
+ }
+
+ return {
+ controls,
+ update,
+ teleport,
+ get position() { return camera.position; },
+ setEnabled: (v) => { enabled = v; },
+ lock: () => controls.lock(),
+ unlock: () => controls.unlock(),
+ get isLocked() { return controls.isLocked; },
+ };
+}
diff --git a/web/js/world/skins.js b/web/js/world/skins.js
new file mode 100644
index 0000000..761421c
--- /dev/null
+++ b/web/js/world/skins.js
@@ -0,0 +1,189 @@
+// PROCITY Lane B — skins.js
+// The city-wide shared-material registry. Every facade/ground/awning skin resolves to ONE
+// cached material used by every chunk (CITY_SPEC: ≤25 skin materials city-wide, share by skin).
+// House law: seeded flat-colour fallback UNDER every texture — the town looks right with zero
+// assets. We use a small onLoad-capable texture cache (not core/loaders.loadTex) precisely so we
+// can keep the flat fallback visible until the JPEG arrives, then swap the map in. GLB depot
+// loading stays on core/loaders (furniture.js).
+
+import * as THREE from 'three';
+import { xmur3 } from '../core/prng.js';
+
+const ASSET_BASE = 'assets/gen/';
+
+// ── muted 90s-AU fallback palettes (used until — or instead of — the JPEG skin) ──
+const FACADE_FALLBACK = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890', '#bfae96'];
+const GROUND_FALLBACK = [
+ [/asphalt|road/, '#4a4a46'], [/footpath|djsim-footpath/, '#8b857a'], [/grass/, '#5d6b45'],
+ [/brickpave/, '#8a6a58'], [/gravel/, '#9a948a'], [/reddust|dust/, '#a8664a'],
+];
+const AWNING_FALLBACK = [[/red/, '#a23b32'], [/green/, '#3a6b45'], [/blue/, '#3a5b8b']];
+
+const hashName = (s) => xmur3(s)();
+const fallbackFacade = (name) => FACADE_FALLBACK[hashName(name) % FACADE_FALLBACK.length];
+const keyword = (name, table, dflt) => (table.find(([re]) => re.test(name)) || [null, dflt])[1];
+
+/**
+ * createSkinLibrary(opts) — one per game session. Materials cached by skin name and shared by
+ * every chunk; dispose() frees textures/materials at teardown.
+ */
+export function createSkinLibrary({ assetBase = ASSET_BASE } = {}) {
+ const texCache = new Map(); // url → THREE.Texture (with fallback colour on its owning material)
+ const matCache = new Map(); // `kind:name` → material
+ const disposables = [];
+
+ // onLoad-capable, cached texture loader. Applies to a material via a swap callback so the
+ // flat fallback colour shows until the image is decoded, then map pops in.
+ const texLoader = new THREE.TextureLoader();
+ function skinTex(url, { repeat, srgb = true, onReady } = {}) {
+ let tex = texCache.get(url);
+ if (tex) { if (onReady && tex.image) onReady(tex); return tex; }
+ tex = texLoader.load(
+ url,
+ (t) => { t.needsUpdate = true; onReady && onReady(t); },
+ undefined,
+ () => console.warn('[skins] tex missing, flat fallback stays:', url),
+ );
+ if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
+ tex.anisotropy = 4;
+ if (repeat) { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(...repeat); }
+ texCache.set(url, tex);
+ disposables.push(tex);
+ return tex;
+ }
+
+ function facadeMat(name) {
+ // [Lane F integration fix] Lane A's registry/plan store facadeSkin as a full filename
+ // ("facade-fibro-blue.jpg"); this fn builds `assets/gen/facade-.jpg`, so a raw filename
+ // double-wraps to `facade-facade-…jpg.jpg` (every facade 404 → flat colour). Normalize to the
+ // bare key — idempotent for a value that is already bare (e.g. the 'weatherboard' fallback).
+ name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, '');
+ const key = `facade:${name}`;
+ let m = matCache.get(key);
+ if (m) return m;
+ m = new THREE.MeshStandardMaterial({ color: new THREE.Color(fallbackFacade(name)), roughness: 0.92, metalness: 0 });
+ skinTex(`${assetBase}facade-${name}.jpg`, { onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
+ matCache.set(key, m); disposables.push(m);
+ return m;
+ }
+
+ // ── facade atlas ────────────────────────────────────────────────────────────────────────────
+ // ONE texture + ONE material for EVERY facade skin, so a chunk's facades merge into a single draw
+ // (vs one mesh per distinct skin — the biggest draw-call term in dense districts) AND ~25 facade
+ // materials collapse to 1. Each slot is pre-painted with the skin's flat fallback colour (house
+ // law: the town is right with zero JPEGs), then the decoded JPEG is drawn over it. UV convention
+ // mirrors the per-chunk sign atlas (CanvasTexture flipY ⇒ v0 = bottom of slot). The stretch of a
+ // square skin across a wide-short shopfront matches the pre-atlas [0,1]² mapping — no regression.
+ const ATLAS_DIM = 2048, ATLAS_COLS = 6, ATLAS_ROWS = 6, SLOT = Math.floor(ATLAS_DIM / ATLAS_COLS);
+ let atlasCanvas = null, atlasCtx = null, atlasTex = null, atlasMat = null, atlasNext = 0;
+ const atlasSlot = new Map(); // bare skin name → [u0,v0,u1,v1]
+
+ function ensureFacadeAtlas() {
+ if (atlasMat) return;
+ atlasCanvas = document.createElement('canvas');
+ atlasCanvas.width = atlasCanvas.height = ATLAS_DIM;
+ atlasCtx = atlasCanvas.getContext('2d');
+ atlasCtx.fillStyle = '#8a7f6f'; atlasCtx.fillRect(0, 0, ATLAS_DIM, ATLAS_DIM);
+ atlasTex = new THREE.CanvasTexture(atlasCanvas);
+ atlasTex.colorSpace = THREE.SRGBColorSpace; atlasTex.anisotropy = 4;
+ atlasTex.generateMipmaps = false; atlasTex.minFilter = THREE.LinearFilter; // NPOT-safe, no cross-slot mip bleed
+ atlasMat = new THREE.MeshStandardMaterial({ map: atlasTex, roughness: 0.92, metalness: 0 });
+ disposables.push(atlasTex, atlasMat);
+ }
+
+ function facadeAtlasMaterial() { ensureFacadeAtlas(); return atlasMat; }
+
+ // UV sub-rect for a skin; assigns + paints a slot on first request (fallback colour now, JPEG on load).
+ function facadeAtlasUV(name) {
+ ensureFacadeAtlas();
+ name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, ''); // accept bare key OR full filename
+ let uv = atlasSlot.get(name);
+ if (uv) return uv;
+ if (atlasNext >= ATLAS_COLS * ATLAS_ROWS) console.warn('[skins] facade atlas full (>36 skins), reusing a slot for', name);
+ const idx = atlasNext++ % (ATLAS_COLS * ATLAS_ROWS);
+ const col = idx % ATLAS_COLS, row = (idx / ATLAS_COLS) | 0;
+ const px = col * SLOT, py = row * SLOT;
+ atlasCtx.fillStyle = fallbackFacade(name); atlasCtx.fillRect(px, py, SLOT, SLOT);
+ atlasTex.needsUpdate = true;
+ const img = new Image(); // same-origin JPEG → canvas never tainted
+ img.onload = () => { atlasCtx.drawImage(img, px, py, SLOT, SLOT); atlasTex.needsUpdate = true; };
+ img.onerror = () => {}; // fallback colour already painted — house law holds
+ img.src = `${assetBase}facade-${name}.jpg`;
+ const g = 0.5 / ATLAS_DIM; // ½px guard against sampling the neighbouring slot
+ uv = [px / ATLAS_DIM + g, 1 - (py + SLOT) / ATLAS_DIM + g, (px + SLOT) / ATLAS_DIM - g, 1 - py / ATLAS_DIM - g];
+ atlasSlot.set(name, uv);
+ return uv;
+ }
+
+ // Ground materials tile via RepeatWrapping; ground.js bakes the tile scale into the mesh UVs,
+ // so one shared material serves strips of any length.
+ function groundMat(name) {
+ const key = `ground:${name}`;
+ let m = matCache.get(key);
+ if (m) return m;
+ m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')), roughness: 0.97, metalness: 0 });
+ const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(0xffffff); m.needsUpdate = true; } });
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
+ matCache.set(key, m); disposables.push(m);
+ return m;
+ }
+
+ function awningMat(name) {
+ const key = `awning:${name}`;
+ let m = matCache.get(key);
+ if (m) return m;
+ m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, AWNING_FALLBACK, '#8a4b3a')), roughness: 0.85, metalness: 0, side: THREE.DoubleSide });
+ skinTex(`${assetBase}tex-awning-${name}.jpg`, { repeat: [3, 1], onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
+ matCache.set(key, m); disposables.push(m);
+ return m;
+ }
+ // Shared awning material for a single instanced mesh per chunk (per-instance colour), so all three
+ // awning skins draw in one call instead of one-per-skin. The stripe JPEG sits on the awning's top
+ // face (unseen from under the verandah), so the flat instance colour reads the same at street level.
+ let _awn = null;
+ function awningMaterial() {
+ if (!_awn) { _awn = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, side: THREE.DoubleSide }); disposables.push(_awn); }
+ return _awn;
+ }
+ function awningColor(name) { return keyword(String(name), AWNING_FALLBACK, '#8a4b3a'); }
+
+ // Generic wallpaper / interior surface (used lightly for house facades until Lane C).
+ function texMat(name, { repeat = [1, 1], rough = 0.9 } = {}) {
+ const key = `tex:${name}:${repeat.join('x')}`;
+ let m = matCache.get(key);
+ if (m) return m;
+ m = new THREE.MeshStandardMaterial({ color: 0xbdb2a2, roughness: rough });
+ skinTex(`${assetBase}tex-${name}.jpg`, { repeat, onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
+ matCache.set(key, m); disposables.push(m);
+ return m;
+ }
+
+ // Sky dome texture for lighting.js (own material there; we just supply the cached texture).
+ function skyTex(name, onReady) {
+ return skinTex(`${assetBase}sky-${name}.jpg`, { onReady });
+ }
+
+ // Shared shell material for InstancedMesh building boxes — per-instance colour via instanceColor,
+ // so ONE material draws every shell in a chunk. Not cached per-name (there is only one).
+ let _shell = null;
+ function shellMaterial() {
+ if (!_shell) { _shell = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }); disposables.push(_shell); }
+ return _shell;
+ }
+ // Shared trim material (parapets, posts, kerbs) — plain instanced, tinted per instance.
+ let _trim = null;
+ function trimMaterial() {
+ if (!_trim) { _trim = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }); disposables.push(_trim); }
+ return _trim;
+ }
+
+ function count() { return matCache.size + (atlasMat ? 1 : 0); } // the atlas is the single shared facade material
+
+ function dispose() {
+ for (const d of disposables) d.dispose && d.dispose();
+ texCache.clear(); matCache.clear(); disposables.length = 0;
+ }
+
+ return { facadeMat, facadeAtlasMaterial, facadeAtlasUV, groundMat, awningMat, awningMaterial, awningColor,
+ texMat, skyTex, shellMaterial, trimMaterial, skinTex, count, dispose, fallbackFacadeColor: fallbackFacade };
+}