PROCITY/web/js/world/tram.js
m3ultra 94e56ed5fa Lane B round 20 (v4.0-beta): the tram ruling — route by shop adjacency + per-town fence (ledger #3/#4)
Verified fresh Chromium across 9 town configs (synthetic, 3 fixtures, 5 real).

TRAM RULING (ledger #3): spinePolyline now walks from each main-subgraph dead-end, greedily taking the
unused main edge fronting the most shops (lot.frontEdge), keeping the best-scoring path (ties -> longer).
SYNTHETIC + FIXTURES BYTE-IDENTICAL by construction AND measurement (a synthetic main node has <=2 mains so
there's never a choice; its two dead-ends tie exactly and strict > keeps the pre-R20 start). Old vs new
logic on the same plan = identical polylines (Boolarra: 7 pts, 6 edges, 810m, 172 shops, 2 stops).

The alpha tram fronted 0 shops on 4 of 5 real towns (it was a highway bus). New: katoomba 0->3, newtown
0->8, fremantle 0->2, bendigo 0->1, castlemaine 1->6. Where the best main chain still fronts <5 shops the
tram is FENCED per-town (Fable's fallback). RUNS: synthetic, all fixtures, newtown (8 shops/1924m/16 stops),
castlemaine (6/2184m/10 stops). FENCED: katoomba (3), fremantle (2), bendigo (1) -- highway, not high street.
The fence keys off the REAL-ROADS signal (edges>200), NEVER shop count alone: the marched fixtures front 0
shops on their mains (bare spine, avenues carry shops) so a naive fence would have killed their v2 tram.
routeInfo{fenced,reason,shopsFronted,routeMetres} exposed for F's smoke.

BUG FOUND+FIXED: the stop projection accepted every shelter in the town with no distance gate -- harmless on
synthetic (2 shelters on the spine), nonsense on real graphs: Newtown made 149 phantom stops (~9 min of dwell
at points the tram never passes). Added a 30m gate (a shelter sits halfRoad+1.7 <=~16m off its centreline, so
genuine stops survive): newtown 149->16, castlemaine 37->10; synthetic 2 / fixtures 3 unchanged.

GROUND POLISH (ledger #4): measured, NO fix needed. Real Katoomba has 325 deg-3+ junctions, 28 under 30 deg,
worst 4.3 deg (near-parallel). At grazing eye level the worst reads clean -- no z-fight, no gaps. Layers are
already y-separated (road 0.0/foot 0.02/kerb 0.06) and road-on-road overlap is invisible by design (OVERLAP=1.5
overruns nodes on purpose; one skin, one y, one merged draw on uniform asphalt). Cosmetic only: footpath/kerb
double up on the sliver between <10deg near-parallel roads -- v4.0 candidate, not a defect.

Selector polish (ledger #5): BLOCKED -- E's towns/index.json not landed (build_towns.py WIP); REAL_TOWNS
hardcode stands, one-line swap when E lands.

-> A (finding, measured): only a slice of shops front a MAIN edge on real towns -- katoomba 7/69, fremantle
   4/79, bendigo 7/35, newtown 32/64, castlemaine 11/22. The real high street classifies as 'side', which is
   WHY three towns need the fence. If the main/side classifier promoted shop-dense ways to main, the fence
   would lift. Note onMain also gates gigs.js pickVenues pub placement.
-> F: tram verdict above; selector gating (HUD vs ?dbg) is your call, one line either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:35:56 +10:00

207 lines
12 KiB
JavaScript

// PROCITY Lane B — tram.js
// v2 tram (?tram=1), default-off, NON-BLOCKING. One small bus/tram runs a seeded out-and-back loop
// along the main-street spine, pausing (door dwell) at each bus_shelter stop (busShelterStops), then
// reversing at the ends. Deterministic: the route + speed + dwell are fixed, so position is a pure
// function of elapsed time — same each run. ≤2 draws (a textured body + a merged headlight pair).
// Composes with weather/night (headlights glow at night). Flag-off identical (shell never builds it).
// Bell-ready: ring() is a stub for the future door-bell audio. No traffic sim, no collision (v0).
import * as THREE from 'three';
import { busShelterStops } from './furniture.js';
const SPEED = 9; // m/s cruising
const DWELL = 3.5; // s door pause at each stop
const LANE = 3.2; // offset from the road centreline (drive on the left, AU)
// Ordered spine polyline: the tram runs the HIGH STREET. Returns [[x,z],…] or null.
//
// ROUND20 ruling (Fable, ledger #3) — route by SHOP ADJACENCY. The alpha walked whatever main chain
// happened to contain the first degree-1 node, which on a real graph is arbitrary (Katoomba: a 3.5 km
// highway chain = 21% of the mains, not the high street). Now: split the main-edge subgraph into
// connected components, score each by the shops fronting its edges (`lot.frontEdge`), and walk the
// winner — ties break on total metres (the longer high street).
//
// SYNTHETIC IS UNMOVED BY CONSTRUCTION: a synthetic town's mains form ONE component, and the walk below
// is seeded by iterating the ORIGINAL `mains` order (filtered to the winner), so `compByNode` is
// insertion-identical to the old `byNode` ⇒ same start node ⇒ byte-identical route. Goldens/feel hold.
function spinePolyline(plan) {
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const mains = plan.streets.edges.filter((e) => e.kind === 'main');
if (!mains.length) return null;
const byNode = new Map();
for (const e of mains) for (const nid of [e.a, e.b]) { if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e); }
// how many shops front each edge (the high-street signal)
const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
const shopsOnEdge = new Map();
for (const s of (plan.shops || [])) {
const l = lotById.get(s.lot);
if (l && l.frontEdge != null) shopsOnEdge.set(l.frontEdge, (shopsOnEdge.get(l.frontEdge) || 0) + 1);
}
const edgeShops = (e) => shopsOnEdge.get(e.id) || 0;
// Walk from `start`, at each junction taking the unused main edge that fronts the MOST shops — i.e.
// follow the retail, which is what "the high street" means. Component logic isn't needed: the walk
// can't leave its component. On a synthetic town every main node has ≤2 mains, so there is never a
// choice and this reduces to the pre-R20 walk exactly.
const walk = (start) => {
const seq = [start]; const used = new Set(); let cur = start, shops = 0, metres = 0;
for (;;) {
const cands = (byNode.get(cur) || []).filter((e) => !used.has(e.id));
if (!cands.length) break;
let nx = cands[0];
for (const c of cands) if (edgeShops(c) > edgeShops(nx)) nx = c; // ties keep `mains` order
used.add(nx.id); shops += edgeShops(nx);
const A = nodes.get(cur), nid = nx.a === cur ? nx.b : nx.a, B = nodes.get(nid);
if (A && B) metres += Math.hypot(B.x - A.x, B.z - A.z);
cur = nid; seq.push(cur);
}
return { seq, shops, metres, edges: used.size };
};
// Candidate starts: every dead-end of the main subgraph, in `mains` order. Score each walk by shops
// fronted (ties → longer). STRICT `>` keeps the FIRST start on a tie, and a synthetic town's two ends
// tie exactly (same chain, reversed) ⇒ the pre-R20 start wins ⇒ byte-identical route.
const starts = [];
for (const [nid, es] of byNode) if (es.length === 1) starts.push(nid);
if (!starts.length) starts.push(mains[0].a);
let best = null;
for (const s of starts) {
const r = walk(s);
if (!best || r.shops > best.shops || (r.shops === best.shops && r.metres > best.metres)) best = r;
}
const poly = best.seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; });
poly.info = { mainEdges: mains.length, candidateStarts: starts.length, routeEdges: best.edges,
shopsFronted: best.shops, shopsTotal: (plan.shops || []).length, routeMetres: Math.round(best.metres) };
return poly;
}
// bus livery texture (canvas — no fetch): cream body, window band, a red stripe + a route blind.
function busTexture() {
const c = document.createElement('canvas'); c.width = 256; c.height = 128;
const x = c.getContext('2d');
x.fillStyle = '#e8e0cf'; x.fillRect(0, 0, 256, 128); // cream
x.fillStyle = '#233043'; x.fillRect(0, 30, 256, 42); // window band
for (let i = 12; i < 256; i += 34) { x.fillStyle = '#9fb2c8'; x.fillRect(i, 34, 24, 34); } // panes
x.fillStyle = '#9a2f2a'; x.fillRect(0, 78, 256, 10); // red stripe
x.fillStyle = '#1a1a1a'; x.fillRect(96, 6, 64, 20); // route blind
x.fillStyle = '#ffd75e'; x.font = 'bold 15px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText('TOWN LOOP', 128, 16);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4;
return t;
}
// ROUND20 per-town fence (Fable's ledger-#3 fallback). On a REAL-ROADS town the best shop-adjacent main
// chain can still be a near-shopless highway — real high streets come out of OSM classified `side` (filed
// for A with measurements), so the tram would be a highway bus, not a town loop. Fence those towns.
//
// The fence keys off the REAL-ROADS signal (`edges > 200`), never on shop count alone — because the marched
// fixtures legitimately front ZERO shops on their mains (the marched spine is bare; the avenues carry the
// shops), and they must keep their v2 tram. Synthetic (22 edges) and fixtures (~15) are far under the
// threshold, so they can never be fenced: behaviour there is untouched.
const REAL_ROADS_EDGES = 200; // a real OSM graph; synthetic/fixtures are an order of magnitude smaller
const FENCE_MIN_SHOPS = 5; // fewer than this on the whole line ⇒ not a high street
export function createTram({ scene, plan, camera, lighting }) {
const route = spinePolyline(plan);
const group = new THREE.Group(); group.name = 'tram';
const realRoads = (plan.streets?.edges?.length || 0) > REAL_ROADS_EDGES;
const shopsFronted = (route && route.info && route.info.shopsFronted) || 0;
const fenced = realRoads && shopsFronted < FENCE_MIN_SHOPS;
if (!route || route.length < 2 || fenced) {
scene.add(group);
const info = { ...((route && route.info) || {}), fenced, reason: fenced ? `real-roads town, best main chain fronts ${shopsFronted} shops (< ${FENCE_MIN_SHOPS}) — highway, not a high street` : (route ? 'no usable spine' : 'no main edges') };
return { group, update() {}, ring() {}, dispose() { scene.remove(group); }, get stops() { return 0; }, get routeInfo() { return info; } };
}
// ── cumulative arc length + stop positions projected to arc length ──
const seg = []; // { x0,z0, dx,dz, len, s0 }
let total = 0;
for (let i = 0; i < route.length - 1; i++) {
const [x0, z0] = route[i], [x1, z1] = route[i + 1];
const dx = x1 - x0, dz = z1 - z0, len = Math.hypot(dx, dz) || 1;
seg.push({ x0, z0, dx: dx / len, dz: dz / len, len, s0: total }); total += len;
}
const posAt = (s) => {
s = Math.max(0, Math.min(total, s));
let g = seg[0]; for (const q of seg) if (s >= q.s0) g = q;
const d = s - g.s0;
return { x: g.x0 + g.dx * d, z: g.z0 + g.dz * d, dx: g.dx, dz: g.dz };
};
// Project each shelter onto the route → sorted arc-length stop marks, keeping only shelters that are
// ACTUALLY ON this route. ROUND20 fix: the projection used to accept every shelter in the town, which
// is harmless on synthetic (2 shelters, both on the spine) but nonsense on a real graph — Newtown's
// ~148 town-wide shelters all projected onto the line, giving 149 phantom stops (~9 min of dwell at
// points the tram never passes). A shelter sits `halfRoad + 1.7` off its own centreline (≤ ~16 m even
// on the wide synthetic main), so 30 m keeps every genuine stop and drops the far ones.
const STOP_NEAR = 30;
const stopS = busShelterStops(plan).map((st) => {
let best = 0, bd = 1e18;
for (let s = 0; s <= total; s += 3) { const p = posAt(s); const dd = (p.x - st.x) ** 2 + (p.z - st.z) ** 2; if (dd < bd) { bd = dd; best = s; } }
return { s: best, d2: bd };
}).filter((x) => x.d2 <= STOP_NEAR * STOP_NEAR).map((x) => x.s).sort((a, b) => a - b);
// ── tram mesh: textured body (1 draw) + merged emissive headlights (1 draw) ──
const bodyMat = new THREE.MeshStandardMaterial({ map: busTexture(), roughness: 0.7, metalness: 0.1 });
const body = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.7, 9.0), bodyMat);
body.position.y = 1.55; body.castShadow = true;
group.add(body);
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: new THREE.Color(0xffe6a0), emissiveIntensity: 0.2, roughness: 0.4 });
const hl = (x) => { const q = new THREE.PlaneGeometry(0.4, 0.3); q.rotateY(0).translate(x, 0.9, 4.55); return q; };
const heads = new THREE.Mesh(mergeQuads([hl(-0.8), hl(0.8)]), headMat);
group.add(heads);
scene.add(group);
// ── deterministic schedule: position s(t) with dwell at stops, ping-pong at the ends ──
let s = 0, dir = 1, dwell = 0, nextStop = 0;
const EPS = 2.5;
function update(dt) {
if (dwell > 0) { dwell = Math.max(0, dwell - dt); }
else {
s += dir * SPEED * dt;
// stop dwell when passing a mark in the travel direction
if (dir > 0 && nextStop < stopS.length && s >= stopS[nextStop] - EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop++; }
else if (dir < 0 && nextStop >= 0 && stopS[nextStop] != null && s <= stopS[nextStop] + EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop--; }
if (s >= total) { s = total; dir = -1; nextStop = stopS.length - 1; }
else if (s <= 0) { s = 0; dir = 1; nextStop = 0; }
}
const p = posAt(s);
// sit in the left lane relative to travel direction, face along travel
const perpx = -p.dz * dir, perpz = p.dx * dir;
group.position.set(p.x + perpx * LANE, 0, p.z + perpz * LANE);
group.rotation.y = Math.atan2(p.dx * dir, p.dz * dir);
headMat.emissiveIntensity = (lighting && lighting.isNight && lighting.isNight()) ? 1.6 : 0.15;
}
update(0);
function ring() { /* door-bell hook — audio parked (V2_IDEAS greenfield) */ }
function dispose() {
body.geometry.dispose(); bodyMat.map.dispose(); bodyMat.dispose();
heads.geometry.dispose(); headMat.dispose();
scene.remove(group);
}
// routeInfo: the R20 shop-adjacency verdict, for F's smoke + the notes (which chain the tram picked).
return { group, update, ring, dispose, get stops() { return stopS.length; },
get routeInfo() { return { ...(route.info || {}), stops: stopS.length, fenced: false }; } };
}
function mergeQuads(geos) {
// tiny local merge (headlight quads share a material) — avoids importing BufferGeometryUtils for 2 quads
let n = 0; for (const g of geos) n += g.attributes.position.count;
const pos = new Float32Array(n * 3), uv = new Float32Array(n * 2), nor = new Float32Array(n * 3);
const idx = []; let vo = 0;
for (const g of geos) {
const gp = g.attributes.position.array, gu = g.attributes.uv.array, gn = g.attributes.normal.array;
pos.set(gp, vo * 3); uv.set(gu, vo * 2); nor.set(gn, vo * 3);
const gi = g.index ? g.index.array : [...Array(g.attributes.position.count).keys()];
for (const i of gi) idx.push(i + vo);
vo += g.attributes.position.count; g.dispose();
}
const m = new THREE.BufferGeometry();
m.setAttribute('position', new THREE.BufferAttribute(pos, 3));
m.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
m.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
m.setIndex(idx);
return m;
}