The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
308 lines
16 KiB
JavaScript
308 lines
16 KiB
JavaScript
// 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;
|
||
|
||
// ── 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
|
||
|
||
// 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) { const ex = extractGLB(g); if (ex) _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;
|
||
}
|
||
|
||
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 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 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));
|
||
}
|
||
// 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;
|
||
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;
|
||
}
|