PROCITY/web/js/world/furniture.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

412 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 13 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 — 13 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;
}