diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index a78152d..bdb23c6 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -67,6 +67,45 @@ contained Lane B change I can land quickly. I'm **not** doing it unilaterally: p (asset tris), and source decimation is the right fix (it helps every town and every view, not just this one). Say the word if you want the B-side gate as belt-and-braces. +## Round 22 — #3 per-town bookmarks (Lane G's filed bug) — FIXED + +**Root cause:** every "nearest" resolver in `dbg.js` sorted by distance from the **world origin**. That's +the town centre only on a *synthetic* plan. A real OSM town is projected about its cache's bbox centre +while its shops cluster hundreds of metres away — so bookmarks teleported into arbitrary far-flung +geometry, exactly as G reported. Measured origin-pick distance from the retail heart: **katoomba 408 m · +newtown 626 m · fremantle 371 m**. + +**Fix:** resolve every bookmark about the town's **densest shop cluster** (grid-bin the shop lots at 120 m, +heaviest bin's centroid = the retail heart) instead of the origin — `byDist`, `lotsByDist`, +`streetViewPose`, `stopPose`, and `crossroadsPose`'s tie-break. + +**Gated on the REAL-ROADS signal (`edges > 200`)** — the same primitive the R20 tram fence uses — so +**synthetic and the marched fixtures keep their exact origin-centred behaviour** and F's release-tour +control shot cannot move. This gate is *load-bearing, not cosmetic*: synthetic's anchor is only 117 m out +but it **does** re-pick (Anglibarn Op Shop → Dusty Pages), so an unconditional switch would have silently +moved the control. Same reason `crossroadsPose`'s new tie-break is gated — a degree tie would otherwise +resolve differently on synthetic. + +| town | edges | ref used | +|---|---|---| +| synthetic · melbourne · katoomba-fixture · silverton | 22 · 15 · 15 · 1 | **origin — unchanged** ✅ | +| **newtown_godverse** (G's town) | 1,626 | anchor (98, −629) ✅ | +| katoomba_real | 993 | anchor (−227, −625) ✅ | + +**Result on katoomba** — shopfront bookmarks went from ~408 m adrift to **≤42 m** of the retail heart: +`arcade` 7 m · `milkbar_dusk`/`patronage_door`/`shopfront_detail` 24 m · `market_square` 26 m · `dig_real` +42 m; strip views (`street_noon`, `katoomba_main`, `district_posters`) ~195 m — on the high street; +`venue_night`/`queue_night` ~345 m (that's A's venue placement, not the bookmark). All 19 resolve, 0 +errors, synthetic 19/19 unchanged. +- `warehouse_fringe` sits **3,074 m** out — **correct by design**: it's the `far:true` fringe shot, and on + a 5 km town the fringe genuinely is 3 km away. Left alone deliberately. + +**→ Lane F (ledger #4, the shared posing primitive):** published on the harness as +**`DBG.townAnchor`** (`{x, z, n, anchored, ref}` — the retail heart + whether it's in use) and +**`DBG.clusterPose(kinds)`** (stand on the road at the densest cluster, look along it). That's your +cluster-pose finding as a `dbg.js`-consumable — the tour and the bookmarks now resolve through the same +primitive, so a tour shot and its bookmark can't drift apart. + ### Honest note on the baseline (mine 163,015 vs F's 221,935) Same view, different number: my automation's rAF is throttled, so the **live gig crowd never spawns** (citizens measured **0** tris here) — F's number includes it (~59k of peds). This doesn't move the verdict: diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js index 6ae83e7..1e49e1a 100644 --- a/web/js/world/dbg.js +++ b/web/js/world/dbg.js @@ -59,9 +59,33 @@ export function installDBG(deps) { look: [frontX, 2.2, frontZ], }; }; + // ── ROUND22 (Lane G's bug): per-town bookmark anchoring ──────────────────────────────────────── + // Every "nearest" resolver below used to sort by distance from the WORLD ORIGIN, which is only the + // town centre on a SYNTHETIC plan. A real OSM town is projected about its cache's bbox centre while + // its shops cluster hundreds of metres away, so origin-sorted bookmarks teleported to arbitrary + // far-flung geometry (measured: the origin pick sits 408 m from the retail heart on katoomba, 626 m + // on newtown, 371 m on fremantle). Fix: sort about the town's DENSEST SHOP CLUSTER instead. + // + // Gated on the REAL-ROADS signal (`edges > 200`, the same primitive the R20 tram fence uses) so + // SYNTHETIC + the marched fixtures keep the exact origin-centred behaviour they've always had — + // their bookmarks (and F's release-tour control shot) do not move. Verified: synthetic's anchor is + // only 117 m out but DOES re-pick, hence the gate rather than an unconditional switch. + const townAnchor = (() => { + const CELL = 120; // ~a block; the heaviest bin is the retail heart + const pts = (plan.shops || []).map(lotOf).filter(Boolean); + if (!pts.length) return { x: 0, z: 0, n: 0 }; + const bins = new Map(); + for (const p of pts) { const k = `${Math.round(p.x / CELL)},${Math.round(p.z / CELL)}`; if (!bins.has(k)) bins.set(k, []); bins.get(k).push(p); } + let best = null; for (const arr of bins.values()) if (!best || arr.length > best.length) best = arr; + return { x: best.reduce((a, p) => a + p.x, 0) / best.length, z: best.reduce((a, p) => a + p.z, 0) / best.length, n: best.length }; + })(); + const REAL_ROADS = (plan.streets?.edges?.length || 0) > 200; + const REF = REAL_ROADS ? townAnchor : { x: 0, z: 0 }; // synthetic/fixtures → origin, exactly as before + const dRef = (x, z) => Math.hypot((x || 0) - REF.x, (z || 0) - REF.z); + const byDist = () => [...(plan.shops || [])].sort((a, b) => { const la = lotOf(a), lb = lotOf(b); - return Math.hypot(la?.x || 0, la?.z || 0) - Math.hypot(lb?.x || 0, lb?.z || 0); + return dRef(la?.x, la?.z) - dRef(lb?.x, lb?.z); }); // nearest shop matching pred, else nearest shop overall → a pose is always produced const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); }; @@ -75,7 +99,7 @@ export function installDBG(deps) { return { pos: [frontX + fx * dist + rx * off, 1.9, frontZ + fz * dist + rz * off], look: [frontX, 2.0, frontZ] }; }; const blockKindOfLot = (lot) => plan.blocks?.find((b) => b.id === lot.block)?.kind; - const lotsByDist = () => [...(plan.lots || [])].sort((a, b) => Math.hypot(a.x, a.z) - Math.hypot(b.x, b.z)); + const lotsByDist = () => [...(plan.lots || [])].sort((a, b) => dRef(a.x, a.z) - dRef(b.x, b.z)); // nearest (or farthest = "fringe") lot matching pred, else nearest lot overall const pickLot = (pred, farthest = false) => { const L = lotsByDist(); const hit = L.filter(pred); @@ -89,11 +113,11 @@ export function installDBG(deps) { const nn = new Map((plan.streets?.nodes || []).map((n) => [n.id, n])); const es = (plan.streets?.edges || []).filter((e) => kinds.includes(e.kind) && nn.get(e.a) && nn.get(e.b)); if (!es.length) return crossroadsPose(); - let best = null, bd = 1e18; // long edge, near the origin (or far, for the fringe) + let best = null, bd = 1e18; // long edge, near REF (or far, for the fringe) for (const e of es) { const a = nn.get(e.a), b = nn.get(e.b); const len = Math.hypot(b.x - a.x, b.z - a.z) || 1; - const dist = Math.hypot((a.x + b.x) / 2, (a.z + b.z) / 2); + const dist = dRef((a.x + b.x) / 2, (a.z + b.z) / 2); // R22: about the town anchor, not the origin const score = (far ? -dist : dist) - len * 0.8; // favour long open runs; `far` picks the outer ring if (score < bd) { bd = score; best = { a, b, len, width: e.width || 8 }; } } @@ -111,7 +135,15 @@ export function installDBG(deps) { const crossroadsPose = () => { const edges = plan.streets?.edges || [], nodes = plan.streets?.nodes || []; const deg = new Map(); for (const e of edges) { deg.set(e.a, (deg.get(e.a) || 0) + 1); deg.set(e.b, (deg.get(e.b) || 0) + 1); } - let node = null, bd = -1; for (const n of nodes) { const d = deg.get(n.id) || 0; if (d > bd) { bd = d; node = n; } } + // busiest node — R22: on a real town, tie-break toward the anchor so we frame the town's own + // crossroads, not a highway junction kilometres away. Synthetic REF is the origin ⇒ unchanged. + let node = null, bd = -1, bdist = 1e18; + for (const n of nodes) { + const d = deg.get(n.id) || 0, dist = dRef(n.x, n.z); + // strict `>` alone on synthetic/fixtures = the pre-R22 "first max-degree node wins" (identical); + // the anchor tie-break only engages on real-roads towns. + if (d > bd || (REAL_ROADS && d === bd && dist < bdist)) { bd = d; bdist = dist; node = n; } + } if (!node) return pickShop(() => true); const e = edges.find((ed) => ed.a === node.id || ed.b === node.id); const o = e && nodes.find((n) => n.id === (e.a === node.id ? e.b : e.a)); @@ -123,7 +155,7 @@ export function installDBG(deps) { const stopPose = () => { const stops = busShelterStops(plan); if (!stops.length) return streetViewPose(['main']); - const s = [...stops].sort((a, b) => Math.hypot(a.x, a.z) - Math.hypot(b.x, b.z))[0]; // nearest origin = most open + const s = [...stops].sort((a, b) => dRef(a.x, a.z) - dRef(b.x, b.z))[0]; // nearest the town anchor (origin on synthetic) const px = Math.sin(s.yaw), pz = Math.cos(s.yaw), ex = Math.cos(s.yaw), ez = -Math.sin(s.yaw); return { pos: [s.x - px * 6 + ex * 4, 2.0, s.z - pz * 6 + ez * 4], look: [s.x, 1.3, s.z] }; }; @@ -192,6 +224,14 @@ export function installDBG(deps) { bookmarks: Object.keys(BOOKMARKS), info, + // ── R22: the town-anchoring primitive, published for Lane F's release tour (ledger #4) ── + // `townAnchor` = the centroid of the densest shop cluster (the retail heart). `anchored` says + // whether the bookmarks are using it (real-roads towns) or the origin (synthetic/fixtures — + // unchanged behaviour). `clusterPose()` is the shared poser: stand on the road at the densest + // cluster and look along it — the same primitive B's bookmarks now resolve through. + townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } }, + clusterPose: (kinds = ['main']) => streetViewPose(kinds), + // time-of-day (hands the day cycle to the harness so captures are deterministic) setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },