// 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 and // runs fully under ?noassets. Mapped types (bench + the novelty_record / food_cart landmark props) // upgrade to depot GLBs via a SYNCHRONOUS "use-if-ready" check at chunk-build time (no async write // into a live chunk): a chunk built before the GLB lands uses its primitive; later chunks use the GLB. import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { loadGLB } from '../core/loaders.js'; import { rng, frange } from '../core/prng.js'; import { chunkOf, resolveEdges } from './planutil.js'; import { applyWind } from './wind.js'; // weather-driven vertex sway on the gum-tree billboards (R17) const FOOT = 3.5; // bus-shelter footprint (from manifest: 2.33 × 1.41 m) + a facing tweak applied to the along-edge // yaw so the open front reads toward the road (verified in-browser; π flips it if the GLB is mirrored). const SHELTER_W = 2.33, SHELTER_D = 1.41, SHELTER_YAW = 0; // ── THE BENCH RULE (one copy; see benchStationsOnEdge below) ──────────────────────────────────── // Cadence and plank length live here because TWO readers need them: the placer inside // buildChunkFurniture and the `benchStops(plan)` enumerator Lane D's sit stations import. const BENCH_S0 = 14, BENCH_STEP = 26, BENCH_END_MARGIN = 6; const BENCH_SEAT_LEN = 1.6; // the plank (primitive template below); D seats two at ±0.38 m const BENCH_KERB_GAP = 0.8; // metres back from the far edge of the footpath // ── 3GOD-depot furniture GLB upgrade (fail-soft; ?noassets → primitives only, zero network) ────── // Each mapped type loads ONCE at module init. buildChunkFurniture checks `_glbReady[type]` // SYNCHRONOUSLY at build time — so a chunk built before the GLB lands uses its primitive, and every // chunk built after uses the GLB (streaming rebuilds pick it up). No async write into a live chunk. const NOASSETS = (() => { try { const p = new URLSearchParams(location.search); return p.has('noassets') && p.get('noassets') !== '0'; } catch { return false; } })(); // Only LIGHT GLBs (≤~8k tris) are enabled — a heavy prop × many instances blows the ≤200k tri gate. // Measured: bench 1.9k ✓, food_cart 5.0k ✓, bin 3.0k ✓, bus_shelter 8.0k ✓ (only 1–3 per town). // [R38 — Lane E correction, recorded] this comment claimed `novelty_record` was **26.5k tris**; it // has been **8,000 since R5** and the number was stale for six epochs. It is still not in GLB_FILES // below, so the primitive is what draws either way — but the reason on file was wrong, and a stale // measurement that nobody re-took is exactly what this project keeps catching itself with. Whether // to wire it now is a draw/tri question for a round that measures it, not a comment fix. const GLB_FILES = { bench: 'procity_street_bench_01.glb', food_cart: 'procity_street_food_cart_01.glb', bin: 'procity_street_bin_01.glb', bus_shelter: 'procity_street_bus_shelter_01.glb', }; const _glbReady = {}; // type → { geo, mat } once (and if) the GLB loads and merges to one mesh // [Lane B R42 §42.5] DEPOT-ASSET ORIENTATION NORMALISATION — measured, not guessed. // A depot GLB is authored to its own axis convention; this file's placement law is "local +Z is the // FRONT" (the primitive bench puts its backrest at z = −0.20, so a sitter faces +Z). The depot bench // does not agree: `procity_street_bench_01.glb` is 0.437 (X) × 0.845 (Y) × 1.215 (Z) and ALL 318 of // its backrest verts (y > 0.55) sit in a thin slab at x ∈ [0.151, 0.219] — i.e. its plank runs along // Z and its sitter faces −X. Placed by a yaw that assumes +Z, every GLB bench in the town stood 90° // out from every primitive one. Rotate the GEOMETRY once at load, not the instance: the station's // yaw then means the same thing whichever geometry is drawn, so `benchStops()` stays one truth and // Lane D's sitters are right on both. +π/2 maps local −X → +Z. Zero draws, zero tris, one rotateY. const GLB_FIX_YAW = { bench: Math.PI / 2 }; // Merge a GLB's meshes into ONE geometry (+ its first material) so the prop can be InstancedMesh'd. // Multi-material GLBs that won't merge return null → the primitive stays (fail-soft). function extractGLB(gltf) { const geos = []; let material = null; gltf.scene.updateWorldMatrix(true, true); gltf.scene.traverse((o) => { if (!o.isMesh || !o.geometry) return; const g = o.geometry.clone(); g.applyMatrix4(o.matrixWorld); for (const name of Object.keys(g.attributes)) if (name !== 'position' && name !== 'normal' && name !== 'uv') g.deleteAttribute(name); g.morphAttributes = {}; if (!g.attributes.normal) g.computeVertexNormals(); if (!g.attributes.uv) g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2)); geos.push(g); if (!material) material = Array.isArray(o.material) ? o.material[0] : o.material; }); if (!geos.length) return null; let merged = null; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; } geos.forEach((g) => g.dispose()); if (!merged) return null; // heterogeneous → keep the primitive return { geo: merged, mat: material || new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 }) }; } if (!NOASSETS) { for (const [key, file] of Object.entries(GLB_FILES)) { loadGLB('depot:' + file).then((g) => { if (!g) return; const ex = extractGLB(g); if (!ex) return; if (GLB_FIX_YAW[key]) ex.geo.rotateY(GLB_FIX_YAW[key]); // → the house "+Z is the front" law _glbReady[key] = ex; }).catch(() => {}); } } // ── 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); // novelty record (record-store landmark): a giant vinyl disc facing +Z on a short post const novelty = mergeGeometries([ box(0.14, 1.3, 0.14, 0, 0.65, 0), // post new THREE.CylinderGeometry(0.85, 0.85, 0.07, 22).rotateX(Math.PI / 2).translate(0, 1.65, 0), // the record new THREE.CylinderGeometry(0.16, 0.16, 0.09, 14).rotateX(Math.PI / 2).translate(0, 1.65, 0.01), // label ], false); // food cart: body + canopy + two posts (primitive fallback for the market food_cart GLB) const foodcart = mergeGeometries([ box(1.5, 0.9, 0.95, 0, 0.65, 0), box(1.7, 0.1, 1.15, 0, 1.55, 0), box(0.07, 0.65, 0.07, -0.75, 1.2, 0.5), box(0.07, 0.65, 0.07, 0.75, 1.2, 0.5), ], false); _tpl = { pole, lamp, bench, bin, busstop, treeGeo, novelty, foodcart }; 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 }), novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }), }; applyWind(_mats.tree, { topY: 4.4 }); // gum-tree canopy sways when weather is on (amp 0 ⇒ byte-identical off) return _mats; } // ── THE FACING CONVENTION, in one place (Lane B R42 §42.5) ────────────────────────────────────── // Every prop in this file is authored with its FRONT along local +Z (bench backrest at z = −0.20, // streetlight arm + lamp head at +Z, shelter's open side at +Z). Under a Y-rotation of `yaw` the // world direction of local +Z is (sin yaw, cos yaw). // // So there are exactly two yaws worth having on a street edge, and until R42 this file only had one: // alongYaw = atan2(ux, uz) → +Z points DOWN THE STREET (what bench + streetlight used) // faceYaw = atan2(−uz, ux) → +Z points along +perp, i.e. ACROSS the street (= alongYaw − π/2) // `perp` is (−uz, ux); an item placed at offset `side * off` on it has the road at `−side * perp`, // so the yaw that faces the road is `faceYaw + (side > 0 ? π : 0)` — the same `side` term the code // already carried, which is why the defect survived: the ± was right and the base was wrong. // MEASURED before the change, off the live scene (60 benches / 89 lamps, seed 20261990, each instance // matched to the edge whose perpendicular offset equals the placement rule — position only, so the // yaw verdict is not circular): bench front vs the road **90.0°, min = median = max, 60/60**; lamp // head gained **0.00 m** toward the road, **0 of 89** reaching over it. After: **0.0°, 60/60** and // **+1.05 m, 89/89** — the whole arm. The comments on both lines ("facing the road", "arm reaches // over the road") had never been true. const faceYawOf = (ux, uz) => Math.atan2(-uz, ux); /** * benchStationsOnEdge(e) → [{ edgeId, kind, s, side, x, z, yaw, seatLen }] * THE bench rule — the only copy in the repo. `buildChunkFurniture` places from it and * `benchStops(plan)` publishes it, so a change here cannot desynchronise the two. * e a resolved edge: { id, kind, width, ax, az, bx, bz } * side +1 / −1 on the furniture perpendicular (−uz, ux) — which footpath the bench stands on * yaw the instance's Y rotation; local +Z (the bench FRONT) points at the road */ function benchStationsOnEdge(e) { 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; const px = -uz, pz = ux; const faceYaw = faceYawOf(ux, uz); const halfRoad = e.width / 2; const out = []; for (let s = BENCH_S0; s < len - BENCH_END_MARGIN; s += BENCH_STEP) { const side = (Math.floor(s / BENCH_STEP) % 2) ? 1 : -1; const off = (halfRoad + FOOT - BENCH_KERB_GAP) * side; out.push({ edgeId: e.id, kind: e.kind, s, side, x: e.ax + ux * s + px * off, z: e.az + uz * s + pz * off, yaw: faceYaw + (side > 0 ? Math.PI : 0), seatLen: BENCH_SEAT_LEN }); } return out; } 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: [], novelty: [], foodcart: [] }; const colliders = []; // oriented-rect keep-outs (bus shelters) → player collision (via chunks.js) 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 faceYaw = faceYawOf(ux, uz); // +Z across the street (see the convention block above) 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)) { // [R42 §42.5] the arm + lamp head sit 1.05 m along local +Z, so this yaw is what decides // whether the light hangs over the carriageway or points up the footpath. It pointed up the // footpath for 41 rounds: measured head gain toward the road was 0.00 m on 89 of 89 lamps. const armYaw = faceYaw + (side > 0 ? Math.PI : 0); // arm reaches over the road pushYaw(lists.pole, x, 0, z, armYaw); pushYaw(lists.lamp, x, 0, z, armYaw); } side = -side; } // benches every ~26m on the building side of the footpath, facing the road — the rule itself // lives in benchStationsOnEdge() so `benchStops(plan)` cannot drift from what is placed here. for (const b of benchStationsOnEdge(e)) { if (here(b.x, b.z)) pushYaw(lists.bench, b.x, 0, b.z, b.yaw); } // bins: genuinely sparse (~1 per block), seeded, one side of the footpath — a country town, not a // mall. Wide cadence also bounds the 3k-tri bin GLB's contribution to the ≤200k tri gate. const binH = hashEdge(e.id); for (let s = 30; s < len - 8; s += 78) { const bside = ((binH + Math.floor(s / 78)) % 2) ? 1 : -1; const off = (halfRoad + FOOT - 0.6) * bside; const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off; if (here(x, z)) pushYaw(lists.bin, x, 0, z, 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); } } } // Bus shelters — 1–3 per town, ON THE STREET GRAPH (they are the future tram/bus stops). // Deterministic rule (see busShelterStops() + LANE_B_NOTES): at the MIDPOINT of every MAIN edge // whose id hashes to 0 mod 3, on the road-side footpath near the kerb, long axis (2.33 m) ALONG // the edge, open front to the road. A solid collider so the player rounds it (collision-verified). if (e.kind === 'main' && hashEdge(e.id) % 3 === 0) { const off = halfRoad + 1.7; // on the footpath, hard by the kerb const sx = e.ax + ux * (len * 0.5) + px * off, sz = e.az + uz * (len * 0.5) + pz * off; // NOTE (R42 §42.5, filed not fixed): `atan2(-uz, ux)` is `faceYawOf(ux, uz)` — the SAME // perpendicular convention the bench and lamp now use, but WITHOUT the `side` term, and the // shelter always stands on +perp. So its open front (+Z) reads AWAY from the road on the // primitive, exactly as the bench's did. Left alone deliberately: `busShelterStops()` yaw is // consumed by tram.js, the shipped shelter is a depot GLB whose own axis is unmeasured, and // this round is three named fixes. Measure the GLB, then flip both together. const syaw = Math.atan2(-uz, ux) + SHELTER_YAW; // long axis along the edge (+ facing tweak) if (here(sx, sz)) { pushYaw(lists.busstop, sx, 0, sz, syaw); colliders.push({ x: sx, z: sz, ry: syaw, hw: SHELTER_W / 2, hd: SHELTER_D / 2, use: 'shelter' }); } } } // ── landmark props keyed to a shop (in front of it on the footpath) ── // Placed with their SHOP's chunk (the shop is in this chunk), so no here() gate → never dropped. const lotById = new Map(chunkData.lots.map((l) => [l.id, l])); for (const shop of (chunkData.shops || [])) { const lot = lotById.get(shop.lot); if (!lot) continue; const ry = lot.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); // front normal const rx = Math.cos(ry), rz = -Math.sin(ry); // right along the frontage const half = (lot.d || 8) / 2; if (shop.type === 'record') { // novelty record outside the record store const fx = lot.x + nx * (half + 1.3) + rx * 1.8, fz = lot.z + nz * (half + 1.3) + rz * 1.8; pushYaw(lists.novelty, fx, 0, fz, ry); // record face toward the street } else if (shop.type === 'stall' || shop.type === 'milkbar') { // food cart by the market / milk bar const fx = lot.x + nx * (half + 1.6), fz = lot.z + nz * (half + 1.6); pushYaw(lists.foodcart, fx, 0, fz, ry); } } 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); }; // GLB-if-ready: use the depot GLB geometry/material once it's loaded, else the primitive fallback. const glb = (key, geo, material) => (_glbReady[key] ? [_glbReady[key].geo, _glbReady[key].mat] : [geo, material]); emit(t.pole, mat.dark, lists.pole); emit(t.lamp, mat.lamp, lists.lamp, false); emit(...glb('bench', t.bench, mat.wood), lists.bench); emit(...glb('bin', t.bin, mat.bin), lists.bin); emit(...glb('bus_shelter', t.busstop, mat.shelter), lists.busstop); emit(t.treeGeo, mat.tree, lists.tree, false); emit(...glb('novelty_record', t.novelty, mat.novelty), lists.novelty); emit(...glb('food_cart', t.foodcart, mat.foodcart), lists.foodcart); 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, colliders }; } 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 // The town's bus/tram stops, derived from the SAME deterministic rule buildChunkFurniture places the // shelters by, so the future vehicle loop can enumerate stops without touching furniture internals. // Rule: midpoint of every MAIN edge whose id hashes to 0 mod 3, on the road-side footpath (kerb). // Returns [{ edgeId, x, z, yaw }] in plan order. Zero side effects; pure function of the plan. export function busShelterStops(plan) { const stops = []; for (const e of resolveEdges(plan)) { if (e.kind !== 'main' || hashEdge(e.id) % 3 !== 0) continue; const dx = e.bx - e.ax, dz = e.bz - e.az, len = Math.hypot(dx, dz) || 1; const ux = dx / len, uz = dz / len, px = -uz, pz = ux, off = e.width / 2 + 1.7; stops.push({ edgeId: e.id, x: e.ax + ux * (len * 0.5) + px * off, z: e.az + uz * (len * 0.5) + pz * off, yaw: Math.atan2(-uz, ux) + SHELTER_YAW }); } return stops; } /** * [Lane B R42 §42.5, for Lane D] benchStops(plan) → the town's benches, from the code that PLACES * them. The shelter twin above exists for the same reason; this one retires the derived mirror in * `citizens/sim.js benchStationsFor()` (LANE_D_NOTES §41: "one line in B's file; D switches to the * import and deletes this"). A mirror is only as good as the day it was copied — the yaw it copied * was wrong, and this round changes it. * * Pure function of the plan: no THREE, no scene, no DOM, no side effects, safe in a worker or Node. * Returns ALL stations in the town — plan edge order, then ascending `s` — not just streamed ones. * * [{ edgeId, // plan edge id (`resolveEdges(plan)` order) * kind, // 'main' | 'side' | 'lane' — the edge's class * s, // metres along the edge from its A node (14, 40, 66, … step 26) * side, // +1 / −1 on the FURNITURE perpendicular (−uz, ux). D's own lane perpendicular is * // (uz, −ux) = −this, so D's own-footpath test stays `side === −forward`. * x, z, // the bench origin — the instance translation, unchanged by R42 * yaw, // the instance's Y rotation. Local +Z is the bench FRONT and now points AT THE * // ROAD (R42 §42.5; it used to point along the street). A rig front is local −Z * // after the R13 facing-normalise, so a sitter is still `yaw + π`. * seatLen // 1.6 m — the plank, so a caller's ±0.38 m two-up offset isn't a second copy of * }] // B's geometry. (A depot-GLB bench is a shorter 1.216 m plank; ±0.38 still fits.) */ export function benchStops(plan) { const stops = []; for (const e of resolveEdges(plan)) for (const b of benchStationsOnEdge(e)) stops.push(b); return stops; }