From a78dc76f8421a1fd50694478133dfe83ca42c2f0 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 16:10:14 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20round=2018:=20the=20real-graph=20lif?= =?UTF-8?q?t=20=E2=80=94=20OSM=20ways=20=E2=86=92=20CityPlan=20street=20gr?= =?UTF-8?q?aph=20(v4.0-alpha=20REAL=20ROADS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The epoch headline (ledger #2). A v2 cache's roads[] now builds the CityPlan street graph FROM real OSM geometry instead of marching shops onto synthetic avenues. SAME CityPlan schema out — B/C/D/F consume it unchanged by contract. roads absent ⇒ the v1 marched fallback, so classic/gig/osm goldens are FROZEN. plan_osm.buildRealRoads: project ways+shops to the local metre frame → simplify each way (Douglas-Peucker eps~6m, the fidelity knob) → snap coincident points (<=3m) to shared nodes = real intersections → straight-segment edges → classify main by OSM highway class + shop density → seat each shop on its NEAREST real edge (real order, real side), marched with regularised spacing + a deterministic overlap-resolve pass. Fidelity (charter risk #4): real edge/order/side + regularised spacing — NOT pixel positions (real shops overlap; the no-overlap invariant is load-bearing). Alpha failure list in LANE_A_NOTES §R18: overflow drop on short edges; intersections = shared-coordinates only (a D sim-graph risk); per-edge frontage-strip blocks not planar faces (a B ground-render item); crowded-intersection overlap-resolve. Proven: a committed synthetic irregular graph (main + two cross streets + a real intersection + an acute angle + a dead-end lane + a curve) passes the FULL structuralSuite + gig district + byte-identical determinism; mutation-tested non-vacuous (flip the facade facing → 'faces its frontEdge' fails). Robust on messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched). All osm goldens frozen; E's 4 trailing caches pinned on v1; katoomba_real UNPINNED (its real-roads golden pins when E lands roads — the A->E->A finalization). selfcheck ALL GREEN 16810/16810; scaffold + consistency GREEN. Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 38 +++++++- docs/CITY_SPEC.md | 22 +++++ docs/LANES/LANE_A_NOTES.md | 43 +++++++++ web/js/citygen/plan_osm.js | 168 +++++++++++++++++++++++++++--------- web/js/citygen/selfcheck.js | 44 +++++++++- 5 files changed, 269 insertions(+), 46 deletions(-) diff --git a/docs/A-progress.md b/docs/A-progress.md index 098b643..6110513 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,10 +1,42 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 2–17 closed** (see below). -v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs -2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).* +*Status: **all deliverables landed; self-check all-green (16810/16810); rounds 2–18 closed** (see below). +v4.0-alpha REAL ROADS: cache schema v2 (roads[]) published first + the real-graph lift (OSM ways → +CityPlan street graph) implemented, proven on a synthetic irregular graph. All osm/classic/gig goldens +frozen; katoomba_real's real-roads golden pins when E's roads land (A→E→A).* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 + the real-graph lift (the epoch headline) + +Lane A is the round's long pole (serial spine A→E→A). Two commits: + +**Commit 1 — cache schema v2 (`roads[]`), published FIRST** (`4dc80c2`) so E's `build_towns.py` can fetch +road geometry. A v2 cache adds `roads:[{kind,pts:[[lat,lon],…]}]` (`kind` = OSM `highway=*`); +`validateTownCache` accepts schema `…/1` AND `…/2` (roads absent ⇒ marched fallback, nothing regresses); +`ROAD_KIND` maps the class to a CityPlan edge kind. Full contract in `web/assets/towns/README.md`. + +**Commit 2 — the real-graph lift** (`plan_osm.buildRealRoads`): real OSM ways → the CityPlan street graph, +**same schema out** (B/C/D/F consume it unchanged). project → simplify (Douglas–Peucker ε≈6 m) → snap +coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify `main` +by OSM class + shop density → seat each shop on its nearest real edge (real order/side), marched + +overlap-resolved. **Passes the full structuralSuite + gig district + determinism** on a committed synthetic +irregular graph (main + two cross streets + intersection + acute angle + dead-end lane + curve). Robust on +messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched fallback). + +Frozen (the alpha changes only the roads path): classic `0x3fa36874`, gig `0xb1d48ea1`, osm melbourne/ +katoomba/silverton, + E's 4 trailing real caches pinned on v1 (bendigo/castlemaine/fremantle/newtown). +`katoomba_real` stays UNPINNED — its golden re-pins to the real-ROADS output when E lands its roads (the +A→E→A finalization). selfcheck ALL GREEN **16810/16810**; scaffold + consistency GREEN. + +Fidelity decision (charter risk #4): real edge + real order + real side + regularised spacing — NOT +pixel-exact (real shops overlap; no-overlap is load-bearing). Alpha failure list (LANE_A_NOTES §R18): (1) +overflow drop on short edges (`norm.dropped`); (2) intersections = shared coordinates only (crossings +without a shared OSM node → disconnected edges — a D sim-graph risk); (3) blocks are per-edge frontage +strips not planar faces (a B ground-render audit item); (4) crowded-intersection overlap-resolve drop. + +Handshake: schema v2 → E (done). "katoomba_real boots with real roads, golden pinned" → B/C/D fires when +E's roads land (katoomba is v1/marched until then). B/C/D audit prep isn't blocked on me. + ## Round 17 (2026-07-16) — v3.2 THE SCOUT: the town-cache contract + plan_osm hardened for real data Lane A ran **first** (E's `build_towns.py` builds to the contract). Scope fence held: **no new plan diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index e80dbe8..91a7625 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -368,6 +368,28 @@ auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig su **Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/ `attribution` fields; raw Overpass responses cached so re-runs never re-fetch). +### Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS) + +> The gap the scout left: the marched fallback lays real shops onto **synthetic** parallel avenues. +> Schema v2 closes it — a cache carries the town's **real OSM street geometry** and `plan_osm` builds the +> CityPlan street graph FROM it. **Opt-in by the cache** (a v2 cache with `roads[]` = real roads; a v1 +> cache, or `roads` absent, = the marched fallback), so default + classic boots are untouched by +> construction (they never load a town cache) and every shipped cache stays valid. + +A **v2** cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` — simplified OSM ways, `kind` the +OSM `highway=*` class (`validateTownCache` accepts schema `…/1` and `…/2`; a road needs ≥2 finite points, +`ROAD_KIND` maps the class to a CityPlan edge kind). The graph lift (`plan_osm.buildRealRoads`): +1. **project** ways + shops to the local metre frame; **simplify** each way (Douglas–Peucker, ε≈6 m — the + fidelity knob, charter risk #4); +2. **snap** coincident points (≤3 m) to shared nodes = **real intersections**; straight-segment **edges**; +3. **classify** `main`/`side` from the OSM class, promoting the shop-densest edge to the `main` spine if + none came from OSM; +4. **seat** each shop on its **nearest real edge** at its real order + real side, marched with regularised + spacing (real shops overlap — pixel-exact positions can't satisfy the no-overlap invariant), a + frontage-strip block per edge, then a deterministic **overlap-resolve** pass (crowded intersections drop + the later-id lot). **Same CityPlan schema out** — B/C/D/F consume it unchanged by contract; the golden + re-pins per the amendment law (`katoomba_real`'s when E's roads land; synthetic + fixtures untouched). + ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index b51c587..a0da97e 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,49 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift + +### → E: **schema v2 is live (committed first) — fetch roads to it** +A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full +contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid → +marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox +as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM +already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds ++ validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden. + +### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph +Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline: +project → simplify (Douglas–Peucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real +intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on +its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full +structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets +(a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched +fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run +against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden). + +### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list +I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions, +because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all +assume it). Written down for the alpha (fixed or fenced): +1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops + drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep + all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.* +2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node + become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph + may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared + coords at junctions (OSM convention) so most are fine. +3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads + these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a + measurement (it's my lot geometry to fix, not yours). +4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in + `norm.dropped`. + +### → B/C/D: handshake state +`katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with +real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the +golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph +lift + the failure list above tell you exactly what irregular geometry to point your audits at. + ## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data ### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake) diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 98f31b0..1f92186 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -14,6 +14,7 @@ import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js'; import { SHOP_TYPES } from '../core/registry.js'; import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js'; +import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots const r2 = v => Math.round(v * 100) / 100; const r4 = v => Math.round(v * 10000) / 10000; @@ -63,6 +64,25 @@ export const ROAD_KIND = { pedestrian: 'arcade', footway: 'arcade', path: 'arcade', steps: 'arcade', cycleway: 'arcade', }; export const roadEdgeKind = hwy => ROAD_KIND[hwy] || 'side'; + +// Douglas–Peucker polyline simplification in projected metres (the road-fidelity knob, charter risk #4). +// Iterative (no recursion-depth risk on a long way). Keeps endpoints + any point > eps off the chord. +function simplifyDP(pts, eps) { + if (pts.length < 3) return pts.slice(); + const keep = new Uint8Array(pts.length); keep[0] = keep[pts.length - 1] = 1; + const stack = [[0, pts.length - 1]]; + while (stack.length) { + const [i0, i1] = stack.pop(); + const a = pts[i0], b = pts[i1], dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz) || 1; + let far = -1, fd = eps; + for (let i = i0 + 1; i < i1; i++) { + const d = Math.abs((pts[i].x - a.x) * dz - (pts[i].z - a.z) * dx) / L; // perpendicular distance to the chord + if (d > fd) { fd = d; far = i; } + } + if (far >= 0) { keep[far] = 1; stack.push([i0, far], [far, i1]); } + } + return pts.filter((_, i) => keep[i]); +} const isNum = v => typeof v === 'number' && Number.isFinite(v); // Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not @@ -175,51 +195,115 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { return id; } - // ── project real shops → local metres; bin by latitude into avenue rows, order by longitude ── + // ── projection to local metres (equirectangular about the cache centre) ── const cosLat = Math.cos(fx.center.lat * Math.PI / 180); - const pts = fx.shops.map(s => ({ - s, x: (s.lon - fx.center.lon) * EARTH_M * cosLat, z: (s.lat - fx.center.lat) * EARTH_M, - })); - const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z)); - const span = (zMax - zMin) || 1; - const rowsArr = Array.from({ length: ROWS }, () => []); - for (const p of pts) { - const row = Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS))); - rowsArr[row].push(p); + const projX = lon => (lon - fx.center.lon) * EARTH_M * cosLat; + const projZ = lat => (lat - fx.center.lat) * EARTH_M; + const footOf = t => { const fp = SHOP_TYPES[t]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; }; + const district = addDistrict('mainstreet', 0, 0); + const roads = Array.isArray(fx.roads) ? fx.roads : []; + + // v2 cache with roads[] ⇒ the real street graph; else the v1 marched fallback (SAME CityPlan out). + if (roads.length) { norm.mode = 'roads'; buildRealRoads(); } else { norm.mode = 'marched'; buildMarched(); } + + // ── v1 marched fallback: bin shops by latitude into synthetic avenues off one S→N spine ── + function buildMarched() { + const pts = fx.shops.map(s => ({ s, x: projX(s.lon), z: projZ(s.lat) })); + const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z)); + const span = (zMax - zMin) || 1; + const rowsArr = Array.from({ length: ROWS }, () => []); + for (const p of pts) rowsArr[Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)))].push(p); + const spineNodes = []; + const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length); + usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); }); + for (let k = 0; k < spineNodes.length - 1; k++) addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main'); + usedRows.forEach((o) => { + const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak + const zPos = o.zPos, fr = rowShops.map(p => footOf(p.s.type)); + const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6; + const avEdge = addEdge(addNode(0, zPos).id, addNode(avLen, zPos).id, AV_W, 'side'); + const half = AV_W / 2, sd = DEPTH + 2; + const block = addBlock(district, 'mainstreet', + [[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]); + const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue + let t = SPINE_CLEAR; + rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; }); + }); } - const district = addDistrict('mainstreet', 0, 0); - // spine: one main street running S→N through the origin, a node per (occupied) row - const spineNodes = []; - const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length); - usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); }); - const spineEdges = []; - for (let k = 0; k < spineNodes.length - 1; k++) spineEdges.push(addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main')); - - // ── each row → an east–west avenue off the spine; its shops march into non-overlapping lots ── - usedRows.forEach((o, k) => { - const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak - const zPos = o.zPos; - // frontages from each shop's registry footprint (deterministic mid-band, no rng needed here) - const fr = rowShops.map(p => { const fp = SHOP_TYPES[p.s.type]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; }); - const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6; - const west = addNode(0, zPos), east = addNode(avLen, zPos); - const avEdge = addEdge(west.id, east.id, AV_W, 'side'); - // block = the frontage strip on the south side of the avenue - const half = AV_W / 2, sd = DEPTH + 2; - const block = addBlock(district, 'mainstreet', - [[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]); - const ry = Math.atan2(0, -1); // outward normal (street→lot) points south (0,-1) ⇒ facade faces +z (north) to the avenue - let t = SPINE_CLEAR; - rowShops.forEach((p, j) => { - const f = fr[j]; - const cx = t + f / 2; - const cz = zPos - (half + DEPTH / 2); - const lot = addLot(block, cx, cz, f, DEPTH, ry, avEdge, 'shop'); - addShop(lot, p.s); - t += f + GAP; - }); - }); + // ── v4 REAL ROADS (schema v2): the CityPlan street graph FROM real OSM ways; shops on real streets ── + // project → simplify (Douglas–Peucker) → snap coincident points to shared nodes (= real intersections) + // → straight-segment edges → classify main by shop density → seat each shop by marching along its + // NEAREST real edge in real order/real side (regularised spacing + an overlap-resolve pass ⇒ the + // stringent structuralSuite still holds). Fidelity is the alpha knob (charter risk #4): real edge, + // real order, real side, regularised spacing — not pixel positions (real shops overlap). + function buildRealRoads() { + const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 6; // fidelity + geometry knobs (metres) + const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 }; + const ways = []; + for (const rd of roads) { + if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) continue; + let pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])).map(([lat, lon]) => ({ x: projX(lon), z: projZ(lat) })); + pl = simplifyDP(pl, EPS); + if (pl.length >= 2) ways.push({ kind: roadEdgeKind(rd.kind), pl }); + } + const nodeAt = new Map(); + const snap = p => { const k = `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`; let n = nodeAt.get(k); if (!n) { n = addNode(p.x, p.z); nodeAt.set(k, n); } return n; }; + const seen = new Set(), realEdges = []; + for (const w of ways) for (let i = 0; i + 1 < w.pl.length; i++) { + const na = snap(w.pl[i]), nb = snap(w.pl[i + 1]); + if (na.id === nb.id || Math.hypot(nb.x - na.x, nb.z - na.z) < 3) continue; + const ek = `${Math.min(na.id, nb.id)}-${Math.max(na.id, nb.id)}`; if (seen.has(ek)) continue; seen.add(ek); + realEdges.push({ id: addEdge(na.id, nb.id, WKIND[w.kind] || AV_W, w.kind), a: na, b: nb, kind: w.kind }); + } + if (!realEdges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; } + const nearest = (px, pz) => { + let best = null, bd = Infinity; + for (const e of realEdges) { + const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, L2 = dx * dx + dz * dz || 1; + let t = ((px - e.a.x) * dx + (pz - e.a.z) * dz) / L2; t = Math.max(0, Math.min(1, t)); + const d = Math.hypot(px - (e.a.x + dx * t), pz - (e.a.z + dz * t)); + if (d < bd) { bd = d; best = { e, t, side: (dx * (pz - e.a.z) - dz * (px - e.a.x)) >= 0 ? 1 : -1 }; } + } + return best; + }; + const perEdge = new Map(); + for (const s of fx.shops) { + const a = nearest(projX(s.lon), projZ(s.lat)); if (!a) continue; + const key = a.e.id * 2 + (a.side > 0 ? 1 : 0); + (perEdge.get(key) || perEdge.set(key, { e: a.e, side: a.side, list: [] }).get(key)).list.push({ s, t: a.t }); + } + if (!realEdges.some(e => e.kind === 'main')) { // classify main from shop density if none from OSM + const cnt = new Map(); for (const { e, list } of perEdge.values()) cnt.set(e.id, (cnt.get(e.id) || 0) + list.length); + let top = null, tc = -1; for (const e of realEdges) { const c = cnt.get(e.id) || 0; if (c > tc) { tc = c; top = e; } } + if (top) { const pe = edges.find(x => x.id === top.id); pe.kind = 'main'; pe.width = SPINE_W; } + } + const cands = []; + for (const { e, side, list } of perEdge.values()) { + list.sort((p, q) => p.t - q.t || p.s.id - q.s.id); + const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len; + const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (−Z) faces road + const off = KERB, dep = DEPTH; + const block = addBlock(district, 'mainstreet', [ + [e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off], + [e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]); + let along = NODE_CLEAR; + for (const { s } of list) { + const f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop'); + if (along + f > len - NODE_CLEAR) break; // edge full — overflow shops drop out (real order kept) + cands.push({ block, x: e.a.x + ux * (along + f / 2) + nx * (off + dep / 2), z: e.a.z + uz * (along + f / 2) + nz * (off + dep / 2), w: f, d: dep, ry, frontEdge: e.id, s }); + along += f + GAP; + } + } + cands.sort((p, q) => p.s.id - q.s.id); // real streets crowd at intersections → deterministic overlap-resolve + const placed = []; + for (const c of cands) { + const corners = lotCorners(c); + if (placed.some(p => obbOverlap(p.corners, corners))) { norm.dropped = (norm.dropped || 0) + 1; continue; } + placed.push({ corners, c }); + } + for (const { c } of placed) addShop(addLot(c.block, c.x, c.z, c.w, c.d, c.ry, c.frontEdge, 'shop'), c.s); + } // ── exactly one late-night landmark: a video rental, closes ≥ LATE_HOUR (matches synthetic) ── if (shops.length) { diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index eed2b37..f99bbf9 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -406,13 +406,55 @@ for (const [label, cache, needle] of [ ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`); ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`); } +// (e) v4 REAL ROADS (schema v2, ROUND18): a v2 cache with roads[] builds the CityPlan street graph FROM +// the real ways and STILL passes the full structural + gig suites. Synthetic irregular graph — a main +// street, two cross streets (a real intersection + an acute-ish angle), a dead-end lane, a slight curve — +// the committed proof of the graph lift; `katoomba_real` validates + pins its golden when E's roads land. +section('v4 real-roads graph lift (schema v2)'); +{ + const roadsCache = { + schema: 'procity-town-cache/2', key: 'roadstest', town: 'Roadstest', source: 'osm', + license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.71, lon: 150.31 }, + roads: [ + { kind: 'primary', name: 'Main St', pts: [[-33.7150, 150.3110], [-33.7130, 150.31108], [-33.7110, 150.31116], [-33.7090, 150.31124], [-33.7070, 150.31132]] }, + { kind: 'residential', name: 'Cross A', pts: [[-33.7130, 150.31108], [-33.7130, 150.3090]] }, + { kind: 'residential', name: 'Cross B', pts: [[-33.7100, 150.3112], [-33.7095, 150.3130]] }, + { kind: 'service', name: 'Back Lane', pts: [[-33.7120, 150.31112], [-33.7122, 150.3122]] }, + ], + shops: Array.from({ length: 18 }, (_, i) => ({ + id: 700 + i, name: `Real St Shop ${i}`, + type: ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7], + lat: -33.7150 + i * 0.00045, lon: 150.3110 + ((i % 3) - 1) * 0.0005, suburb: 'Katoomba', + })), + }; + const v = validateTownCache(roadsCache); + ok(v.ok, `roads cache: validates as schema v2` + (v.ok ? '' : ` — ${v.errors.join('; ')}`)); + for (const s of [1, 42, 20261990]) { + const p = generatePlanOSM(s, 'roadstest', { cache: roadsCache }); + ok(p.streets.edges.length >= 3, `roads ${s}: multi-edge real graph (${p.streets.edges.length} edges from real ways)`); + ok(p.streets.edges.some(e => e.kind === 'main'), `roads ${s}: a main spine exists (OSM class or shop-density promoted)`); + ok(p.shops.length >= 6, `roads ${s}: ${p.shops.length} shops seated on real edges (overlap-resolved)`); + structuralSuite(p, `roads syn ${s}`); + } + ok(JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })) === JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })), + `roads: deterministic (byte-identical re-run)`); + districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'roadstest', cache: roadsCache }), `roads syn`); + // roads-absent (v1) marches — proven identical to before by the frozen osm goldens above + ok(generatePlanOSM(1, 'x', { cache: { ...roadsCache, roads: undefined } }).streets.edges.some(e => e.kind === 'main'), + `roads absent: marched fallback still boots`); +} // ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ── // Empty until E's caches land; each is validated, run through the full structural + gig suites, and // pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive. section('real town caches (web/assets/towns/*.json)'); const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns'); -const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: : 0x… */ }; +// The trailing towns are pinned on their stable v1 (marched) output — regression guards. katoomba_real +// stays UNPINNED this round: it is the v4.0-alpha town, and its golden re-pins to the real-ROADS output +// the moment E lands its roads[] (amendment law — the A→E→A finalization). The other four get roads in beta. +const REAL_TOWN_GOLDENS = { + bendigo_real: 0x5e3e76a6, castlemaine_real: 0xf1a628a1, fremantle_real: 0xee9fd09a, newtown_real: 0x95978b45, +}; const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json')); if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)"); for (const f of townFiles) {