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>
This commit is contained in:
parent
62cbb955b1
commit
94e56ed5fa
@ -1,5 +1,70 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 20 (Fable's ROUND20 → Lane B) — the tram ruling + ground polish (ledger #3/#4, v4.0-beta)
|
||||
|
||||
### 1. The tram ruling (ledger #3) — route by SHOP ADJACENCY, fence per-town
|
||||
`tram.js` `spinePolyline` now walks from each dead-end of the main subgraph, **greedily taking the
|
||||
unused main edge that fronts the most shops** (`lot.frontEdge`), and keeps the best-scoring path (ties →
|
||||
longer). Component logic isn't needed — a walk can't leave its component.
|
||||
|
||||
**Synthetic + fixtures are byte-identical, by construction and by measurement.** A synthetic main node has
|
||||
≤2 mains so there's never a choice, and its two dead-ends tie exactly ⇒ strict `>` keeps the pre-R20 start
|
||||
⇒ the same route. Verified by running the old and new logic against the same plan: **identical polylines**
|
||||
(Boolarra Heads: 7 points, 6 edges, 810 m, 172 shops fronted, 2 stops — unchanged).
|
||||
|
||||
| town | shops | shops fronting mains | OLD route | NEW route | stops | tram |
|
||||
|---|---|---|---|---|---|---|
|
||||
| synthetic | 493 | 172 | 172 | **172** (810 m) | 2 | RUNS (unchanged) |
|
||||
| fixture melbourne / katoomba | 95 / 19 | 0 / 0 | 0 | 0 (378 m) | 3 | RUNS (unchanged) |
|
||||
| fixture silverton | 12 | — (0 mains) | — | — | 0 | no-op (unchanged) |
|
||||
| **REAL newtown** | 64 | 32 | **0** | **8** (1924 m) | 16 | **RUNS** |
|
||||
| **REAL castlemaine** | 22 | 11 | 1 | **6** (2184 m) | 10 | **RUNS** |
|
||||
| REAL katoomba | 69 | 7 | **0** | 3 (3784 m) | — | **FENCED** |
|
||||
| REAL fremantle | 79 | 4 | **0** | 2 (3054 m) | — | **FENCED** |
|
||||
| REAL bendigo | 35 | 7 | **0** | 1 (4376 m) | — | **FENCED** |
|
||||
|
||||
The alpha tram fronted **0 shops on 4 of 5 real towns** — it really was a highway bus. Shop-adjacency
|
||||
improves every town, but on katoomba/fremantle/bendigo the best main chain still fronts <5 shops, so per
|
||||
Fable's fallback the tram is **fenced per-town**.
|
||||
|
||||
**The fence keys off the REAL-ROADS signal (`edges > 200`), never shop count alone** — because the marched
|
||||
fixtures legitimately front **0 shops on their mains** (the marched spine is bare; the avenues carry the
|
||||
shops), so a naive shop-count fence would have killed their v2 tram. Synthetic (22 edges) and fixtures (15)
|
||||
are an order of magnitude under the threshold and can never be fenced. Threshold: `shopsFronted < 5`.
|
||||
|
||||
### 2. Bug found + fixed en route: phantom tram stops on real graphs
|
||||
The stop projection mapped **every shelter in the town** onto the route with no distance gate — harmless on
|
||||
synthetic (2 shelters, both on the spine), nonsense on a real graph: **Newtown produced 149 stops** (~9 min
|
||||
of pure dwell at points the tram never passes). Added a **30 m** gate (a shelter sits `halfRoad+1.7` ≈ ≤16 m
|
||||
off its own centreline, so every genuine stop survives): **newtown 149 → 16, castlemaine 37 → 10**;
|
||||
synthetic **2** and fixtures **3** unchanged.
|
||||
|
||||
### 3. Ground polish at real intersections (ledger #4) — measured, NO fix needed
|
||||
Real Katoomba: **325 degree-3+ junctions, 28 under 30°, worst 4.3°** (near-parallel). Viewed at grazing eye
|
||||
level (the worst case for z-fighting) the worst junction reads **clean — no z-fight, no gaps**: continuous
|
||||
tarmac, correct footpath/kerb. Why it holds: the layers are already y-separated (road `0.0` / footpath
|
||||
`0.02` / kerb `0.06`) so nothing co-plane-fights across layers, and road-on-road overlap at junctions is
|
||||
**invisible by design** — `OVERLAP = 1.5` makes strips overrun their nodes on purpose, and they share one
|
||||
skin, one y and one merged draw, so a UV mismatch can't read on uniform asphalt.
|
||||
- *Cosmetic only (v4.0 candidate, not a defect):* at ultra-acute (<10°) junctions the footpath/kerb strips
|
||||
of the two near-parallel roads double up on the sliver between them. Reads busy, not broken.
|
||||
|
||||
### 4. Selector polish (ledger #5) — BLOCKED on E
|
||||
E's `web/assets/towns/index.json` hasn't landed (`build_towns.py` still WIP), so `REAL_TOWNS` stays
|
||||
hardcoded this round. It's a one-line swap to `fetch('assets/towns/index.json')` when E lands.
|
||||
|
||||
**→ Lane F (B→F tram verdict + selector):** tram **runs** on synthetic, all fixtures, **newtown** and
|
||||
**castlemaine**; **fenced** on **katoomba, fremantle, bendigo** (`routeInfo.fenced` + `reason` are exposed
|
||||
for your smoke). Selector gating (always-on HUD vs `?dbg`) is your call — one line either way.
|
||||
|
||||
**→ Lane A (finding, with measurements):** only a small 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 is coming out classified **`side`**, which is *why* the tram must be fenced on three towns. If your
|
||||
main/side classifier promoted shop-dense ways to `main`, the fence would lift and the tram would find the
|
||||
actual high street. Worth a look beyond the tram: **`onMain` also gates `gigs.js pickVenues` pub placement**.
|
||||
|
||||
---
|
||||
|
||||
## Round 19 (Fable's ROUND19 → Lane B) — the town selector (ledger #4, beta pull-forward, non-blocking)
|
||||
|
||||
Shipped the in-game town picker; verified fresh Chromium. **No `index.html` seam** — it lives in `hud.js`,
|
||||
|
||||
@ -13,23 +13,66 @@ 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: walk the chain of MAIN edges from a spine end. Returns [[x,z],…] or null.
|
||||
// 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); }
|
||||
let start = null;
|
||||
for (const [nid, es] of byNode) if (es.length === 1) { start = nid; break; }
|
||||
if (start == null) start = mains[0].a;
|
||||
const seq = [start]; const used = new Set(); let cur = start;
|
||||
for (;;) {
|
||||
const next = (byNode.get(cur) || []).find((e) => !used.has(e.id));
|
||||
if (!next) break;
|
||||
used.add(next.id); cur = next.a === cur ? next.b : next.a; seq.push(cur);
|
||||
|
||||
// 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);
|
||||
}
|
||||
return seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; });
|
||||
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.
|
||||
@ -47,10 +90,28 @@ function busTexture() {
|
||||
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';
|
||||
if (!route || route.length < 2) { scene.add(group); return { group, update() {}, ring() {}, dispose() { scene.remove(group); } }; }
|
||||
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 }
|
||||
@ -66,12 +127,18 @@ export function createTram({ scene, plan, camera, lighting }) {
|
||||
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 stop onto the route → sorted arc-length stop marks
|
||||
// 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 best;
|
||||
}).sort((a, b) => a - b);
|
||||
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 });
|
||||
@ -113,7 +180,9 @@ export function createTram({ scene, plan, camera, lighting }) {
|
||||
heads.geometry.dispose(); headMat.dispose();
|
||||
scene.remove(group);
|
||||
}
|
||||
return { group, update, ring, dispose, get stops() { return stopS.length; } };
|
||||
// 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user