PROCITY/web/js/citygen/plan_osm.js
m3ultra 6fc4b40632 Lane A round 19: the v4.0-alpha close — fragmentation ruling + findings hardened + goldens pinned
A's finalization (ledger #1), the round's spine. Every filed R18/R19 finding resolved; the tag is F's.

- Fragmentation (D's finding; Fable ruled, A owns details). The naive real graph fragmented (katoomba 105
  components, 58% street-metres on islands). plan_osm.buildRealRoads now: (a) JUNCTION-PROTECTED
  Douglas-Peucker — a point shared by >=2 ways is a junction; simplify only BETWEEN junctions, never
  through one (the collinear tee-off drop, the root cause) → 94-100% main-net alone; (b) cull/bridge —
  main component + near-fragments joined (JOIN_TOL 12m; shop-island bridge <=200m), far SHOPLESS islands
  culled. Result: 1 connected component on all 5 towns (katoomba 100% main-net, 2 islands culled). Every
  dropped shop COUNTED in norm.dropped (ruling): katoomba 19/20 (1 crowd drop — R18 overflow prediction
  measured), the other four kept all.
- E's poster-clearance finding fixed (gigs.js): a spine poster overhanging a real crossing carriageway is
  skipped, measured exactly as the selfcheck kerb floor — a NO-OP on synthetic (gig golden frozen, pr()
  consumed before the check). 23 real-town clearance failures → 0.
- Goldens pinned (A->E->A finalization): all 5 real towns on their real-roads output (katoomba 0xb7ffbca2,
  newtown 0x6f984c81, fremantle 0xfb197000, bendigo 0x21179b3b, castlemaine 0x7c47f639). synthetic
  0x3fa36874 / gig 0xb1d48ea1 / marched fixtures / classic / default FROZEN — the alpha changed nothing
  outside the cache-schema path. selfcheck ALL GREEN 51670/51670; scaffold + consistency GREEN. Cross-
  process deterministic.
- New finding filed (beta, non-blocking): spine-poster density scales with edges (katoomba 438 vs ~14
  synthetic; draws low via instancing) → a beta posters-per-town cap.

Handshake fired → B/C/D/F: katoomba_real boots real roads, golden pinned, findings resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:37:01 +10:00

402 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY CityGen — the OSM plan source (Lane A, round 6). Second producer behind the same
// CityPlan contract as plan.js, so Lanes BF consume it unchanged. Selected via ?plansrc=osm
// (default stays 'synthetic', byte-identical — see index.js `generatePlanFor`).
//
// Input: a CHECKED-IN fixture of real inner-Melbourne secondhand/charity/music/book/toy/antique
// shops (osm_fixture.js, extracted from thriftgod's Overpass cache). ZERO network at runtime.
//
// How real data drives the plan (documented simplification — the honest "cut"): a shop's real
// LATITUDE picks which eastwest avenue it lands on, and its real LONGITUDE sets its order along
// that avenue. Avenue spacing and per-lot frontage are regularized (marched) so the result is a
// clean, non-overlapping, valid CityPlan — the geography is real in row+order, stylized in metres.
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
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;
const clampHour = h => Math.max(0, Math.min(23, h | 0));
const LATE_HOUR = 22; // matches plan.js: exactly one shop closes ≥ this
const EARTH_M = 111320; // metres per degree latitude (equirectangular)
const ROWS = 8; // latitude bands → avenues
const ROW_DZ = 54; // metres between avenues (> avenue depth ⇒ blocks disjoint)
const AV_W = 12, SPINE_W = 24; // corridor widths
const SPINE_CLEAR = 16; // lots start this far east of the spine (no lot on the road)
const GAP = 2, DEPTH = 14; // lot gap + depth along/into the avenue
// Short signboard form from a real shop name: drop a leading "The", take words up to ~15 chars.
function signOf(name) {
const words = String(name).replace(/^the\s+/i, '').split(/\s+/);
let s = '';
for (const w of words) { if ((s + ' ' + w).trim().length > 15) break; s = (s + ' ' + w).trim(); }
return (s || String(name).slice(0, 15)).toUpperCase();
}
// ── the TOWN-CACHE CONTRACT (ROUND17 ledger #6, Lane A owns it) ─────────────────────────────────────
// What `plan_osm` accepts. E's pipeline (build_towns.py) produces one JSON per real town under
// web/assets/towns/<key>.json in THIS shape; plan_osm marches it exactly like the checked-in fixtures —
// the scout proves the EXISTING contract eats real data, no new plan fields, no new geometry.
//
// { schema:"procity-town-cache/1", key, town, source:"osm",
// license, attribution, // ODbL — REQUIRED on shipped real caches (+ SOURCES.md, E)
// center:{lat,lon}, // REQUIRED — equirectangular projection origin
// bbox?, counts?, fetchedAt?, // optional provenance/metadata (geometry ignores these)
// shops:[ { id, name, type, lat, lon, suburb? } ] } // REQUIRED, >= MIN_TOWN_SHOPS
//
// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed —
// same as the fixtures). A blank name defaults to the type label. `suburb` is optional.
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/2'; // v2 (ROUND18) adds optional roads[]
const ACCEPTED_SCHEMAS = new Set(['procity-town-cache/1', 'procity-town-cache/2']); // v1 stays valid → marched
export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare
export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note)
// ── roads[] (schema v2, ROUND18) ─────────────────────────────────────────────────────────────────
// OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road
// dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'.
export const ROAD_KIND = {
motorway: 'main', trunk: 'main', primary: 'main', secondary: 'main',
tertiary: 'side', unclassified: 'side', residential: 'side', living_street: 'side', road: 'side',
service: 'lane', track: 'lane',
pedestrian: 'arcade', footway: 'arcade', path: 'arcade', steps: 'arcade', cycleway: 'arcade',
};
export const roadEdgeKind = hwy => ROAD_KIND[hwy] || 'side';
// DouglasPeucker 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
// boot a valid district; warnings are absorbed (remapped type, defaulted name, missing licence).
export function validateTownCache(cache) {
const errors = [], warnings = [];
if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings };
if (cache.schema && !ACCEPTED_SCHEMAS.has(cache.schema)) warnings.push(`unknown schema '${cache.schema}' (want one of ${[...ACCEPTED_SCHEMAS].join(', ')})`);
const c = cache.center;
if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers');
if (!Array.isArray(cache.shops)) errors.push('shops must be an array');
else {
if (cache.shops.length < MIN_TOWN_SHOPS) errors.push(`>= ${MIN_TOWN_SHOPS} shops required (got ${cache.shops.length})`);
let badCoord = 0, blankName = 0, unknownType = 0, dupId = 0; const ids = new Set();
for (const s of cache.shops) {
if (!s || !isNum(s.lat) || !isNum(s.lon)) badCoord++;
if (!s || !(typeof s.name === 'string' && s.name.trim())) blankName++;
if (!s || !SHOP_TYPES[s.type]) unknownType++;
if (s) { if (ids.has(s.id)) dupId++; else ids.add(s.id); }
}
if (badCoord) errors.push(`${badCoord} shop(s) with non-finite lat/lon`);
if (blankName) warnings.push(`${blankName} blank shop name(s) → defaulted to type label`);
if (unknownType) warnings.push(`${unknownType} unknown shop type(s) → remapped to opshop`);
if (dupId) warnings.push(`${dupId} duplicate shop id(s)`);
// wide-spread warning: an over-broad Overpass bbox marches into an unusable multi-km strip (the R17
// real-data risk). Metadata only — plan_osm still boots a valid plan; it just isn't a coherent town.
let mnLat = Infinity, mxLat = -Infinity, mnLon = Infinity, mxLon = -Infinity;
for (const s of cache.shops) if (s && isNum(s.lat) && isNum(s.lon)) {
mnLat = Math.min(mnLat, s.lat); mxLat = Math.max(mxLat, s.lat);
mnLon = Math.min(mnLon, s.lon); mxLon = Math.max(mxLon, s.lon);
}
if (mxLat > mnLat) {
const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180);
if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`);
}
}
// v2: optional roads[] — simplified OSM ways. Each is `{ kind, pts:[[lat,lon],…] }` (id/name optional);
// `kind` is the OSM highway=* class. Absent (or a v1 cache) ⇒ the marched-avenue fallback, so nothing
// shipped regresses. A road needs ≥2 finite points; <2 is dropped, an unmapped kind falls to 'side'.
if (cache.roads !== undefined) {
if (!Array.isArray(cache.roads)) errors.push('roads must be an array (when present)');
else {
let badRoad = 0, shortRoad = 0, unknownKind = 0;
for (const rd of cache.roads) {
if (!rd || typeof rd.kind !== 'string' || !Array.isArray(rd.pts)) { badRoad++; continue; }
if (rd.pts.length < 2) { shortRoad++; continue; }
if (rd.pts.some(p => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1]))) badRoad++;
if (!ROAD_KIND[rd.kind]) unknownKind++;
}
if (badRoad) errors.push(`${badRoad} malformed road(s) — need { kind:string, pts:[[lat,lon],…] }`);
if (shortRoad) warnings.push(`${shortRoad} road(s) with < 2 points → dropped`);
if (unknownKind) warnings.push(`${unknownKind} road(s) with an unmapped highway kind → 'side'`);
}
}
if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)');
return { ok: errors.length === 0, errors, warnings };
}
// A runtime registry of validated town caches (E's real caches; the selfcheck + shell register them).
// generatePlanOSM resolves a town from opts.cache, then this registry, then the checked-in fixtures.
const TOWN_CACHES = {};
export function registerTownCache(key, cache) {
const v = validateTownCache(cache);
if (!v.ok) throw new Error(`town cache '${key}' invalid: ${v.errors.join('; ')}`);
TOWN_CACHES[key] = cache;
return v;
}
export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0);
const fx = opts.cache || TOWN_CACHES[town] || OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN];
// Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data
// "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan.
const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' };
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
const addBlock = (district, kind, poly) => { const id = bid++; blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) }); return id; };
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
const id = lid++;
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
return id;
};
// Create a shop record from a real fixture entry (real name; seeded facade/hours/storeys).
function addShop(lotId, fixtureShop) {
const id = sid++;
const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop';
if (type !== fixtureShop.type) norm.typesRemapped++; // unknown OSM kind → opshop (normalization)
const reg = SHOP_TYPES[type];
const seed = seedFor(citySeed, 'osmshop', fixtureShop.id);
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
const storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
let open = reg.hours.open, close = reg.hours.close;
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
// real OSM data has the odd unnamed shop — default a blank name to the registry label so the sign
// never renders "undefined" (build_towns.py filters most, but plan_osm must not trust its input).
const nm = (typeof fixtureShop.name === 'string' && fixtureShop.name.trim()) ? fixtureShop.name : (reg.label || type);
if (nm !== fixtureShop.name) norm.namesDefaulted = (norm.namesDefaulted || 0) + 1;
shops.push({ id, lot: lotId, type, name: nm, sign: signOf(nm),
seed, facadeSkin, storeys, hours: [open, close] });
return id;
}
// ── projection to local metres (equirectangular about the cache centre) ──
const cosLat = Math.cos(fx.center.lat * Math.PI / 180);
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; });
});
}
// ── v4 REAL ROADS (schema v2): the CityPlan street graph FROM real OSM ways; shops on real streets ──
// project → simplify (DouglasPeucker) → 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 JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
const skey = p => `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`;
const rawWays = [];
for (const rd of roads) {
if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) continue;
const pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])).map(([lat, lon]) => ({ x: projX(lon), z: projZ(lat) }));
if (pl.length >= 2) rawWays.push({ kind: roadEdgeKind(rd.kind), pl });
}
// ROUND19: junctions = points shared by ≥2 ways. PROTECT them from DouglasPeucker — simplify only
// BETWEEN junctions, never through one. The collinear junction-drop (a straight way losing the point
// where a side street tees in) is what fragmented the graph into 105 components (D + B, R18).
const keyWays = new Map();
rawWays.forEach((w, wi) => { for (const p of w.pl) { const k = skey(p); (keyWays.get(k) || keyWays.set(k, new Set()).get(k)).add(wi); } });
const isJct = k => (keyWays.get(k)?.size || 0) >= 2;
const ways = [];
for (const w of rawWays) {
const brk = [0]; for (let i = 1; i < w.pl.length - 1; i++) if (isJct(skey(w.pl[i]))) brk.push(i); brk.push(w.pl.length - 1);
let out = [];
for (let b = 0; b + 1 < brk.length; b++) { const s = simplifyDP(w.pl.slice(brk[b], brk[b + 1] + 1), EPS); out = out.concat(b === 0 ? s : s.slice(1)); }
if (out.length >= 2) ways.push({ kind: w.kind, pl: out });
}
// ── build a CANDIDATE graph (local ids), resolve fragmentation, THEN commit the survivors ──────────
// ROUND19 ruling (Fable): the town is the main component + joined near-fragments; a far SHOPLESS island
// is bbox-clipped noise + dead streaming budget, so it's CULLED. A shop-bearing island is bridged so no
// shop strands; a shop we still can't reach drops with a count. (Committing after resolution is what
// lets us cull — addEdge can't un-add.)
const cnodes = [], cnodeAt = new Map();
const cnode = p => { const k = skey(p); let id = cnodeAt.get(k); if (id === undefined) { id = cnodes.length; cnodes.push({ x: p.x, z: p.z }); cnodeAt.set(k, id); } return id; };
const cedges = [], eseen = new Set();
for (const w of ways) for (let i = 0; i + 1 < w.pl.length; i++) {
const a = cnode(w.pl[i]), b = cnode(w.pl[i + 1]);
if (a === b) continue;
const ek = `${Math.min(a, b)}-${Math.max(a, b)}`; if (eseen.has(ek)) continue; eseen.add(ek);
if (Math.hypot(cnodes[a].x - cnodes[b].x, cnodes[a].z - cnodes[b].z) < 3) continue;
cedges.push({ a, b, kind: w.kind });
}
if (!cedges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; }
const par = cnodes.map((_, i) => i); const find = x => { while (par[x] !== x) x = par[x] = par[par[x]]; return x; };
const clen = (a, b) => Math.hypot(cnodes[a].x - cnodes[b].x, cnodes[a].z - cnodes[b].z);
for (const e of cedges) par[find(e.a)] = find(e.b);
const compLen = new Map(); for (const e of cedges) { const r = find(e.a); compLen.set(r, (compLen.get(r) || 0) + clen(e.a, e.b)); }
let mainRoot = -1, mainLen = -1; for (const [r, L] of compLen) if (L > mainLen) { mainLen = L; mainRoot = r; }
const nearestC = (px, pz) => { let be = -1, bd = Infinity; for (let i = 0; i < cedges.length; i++) { const e = cedges[i], A = cnodes[e.a], B = cnodes[e.b], dx = B.x - A.x, dz = B.z - A.z, L2 = dx * dx + dz * dz || 1; let t = ((px - A.x) * dx + (pz - A.z) * dz) / L2; t = Math.max(0, Math.min(1, t)); const d = Math.hypot(px - (A.x + dx * t), pz - (A.z + dz * t)); if (d < bd) { bd = d; be = i; } } return be; };
const shopRoot = new Set();
for (const s of fx.shops) { const ei = nearestC(projX(s.lon), projZ(s.lat)); if (ei >= 0) shopRoot.add(find(cedges[ei].a)); }
const rootNodes = new Map(); cnodes.forEach((_, i) => { const r = find(i); (rootNodes.get(r) || rootNodes.set(r, []).get(r)).push(i); });
const inMain = i => find(i) === find(mainRoot);
const connectors = []; let culled = 0;
for (const r of [...compLen.keys()].filter(r => r !== mainRoot).sort((a, b) => (compLen.get(b) || 0) - (compLen.get(a) || 0))) {
if (inMain(r)) continue; // already merged by an earlier bridge
const grp = rootNodes.get(r) || [], shop = shopRoot.has(find(grp[0]));
let ga = -1, gb = -1, bd = Infinity; // nearest (fragment node, main-net node) pair
for (const i of grp) for (let j = 0; j < cnodes.length; j++) { if (!inMain(j)) continue; const d = clen(i, j); if (d < bd) { bd = d; ga = i; gb = j; } }
if (bd <= JOIN_TOL || (shop && bd <= BRIDGE_MAX)) { connectors.push([ga, gb]); par[find(ga)] = find(gb); }
else culled++; // shopless (or unreachable-shop) far island → cull
}
const nodeMap = new Map();
const putNode = ci => { let n = nodeMap.get(ci); if (!n) { n = addNode(cnodes[ci].x, cnodes[ci].z); nodeMap.set(ci, n); } return n; };
const realEdges = [];
for (const e of cedges) { if (!inMain(e.a)) continue; const na = putNode(e.a), nb = putNode(e.b); realEdges.push({ id: addEdge(na.id, nb.id, WKIND[e.kind] || AV_W, e.kind), a: na, b: nb, kind: e.kind }); }
for (const [a, b] of connectors) { const na = putNode(a), nb = putNode(b); realEdges.push({ id: addEdge(na.id, nb.id, WKIND.lane, 'lane'), a: na, b: nb, kind: 'lane' }); }
norm.components = compLen.size; norm.culledIslands = culled; norm.bridges = connectors.length;
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, d, 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 || a.d > SEAT_MAX) { norm.dropped = (norm.dropped || 0) + 1; continue; } // stranded past bridging → drop + COUNT (ruling)
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 (let k = 0; k < list.length; k++) {
const s = list[k].s, f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop');
if (along + f > len - NODE_CLEAR) { norm.dropped = (norm.dropped || 0) + (list.length - k); break; } // edge full — overflow drops COUNTED (ruling)
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) {
const videos = shops.filter(s => s.type === 'video');
const pool = videos.length ? videos : shops.filter(s => s.type !== 'stall');
norm.openLate = videos.length ? 'video' : (pool.length ? pool[0].type + ' (no video in town)' : 'none');
if (pool.length) {
const lr = rng(citySeed, 'osm-openlate', 0);
const chosen = pick(lr, pool);
chosen.openLate = true;
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)];
}
}
// Centre the imported town on the origin and report its true bounding box as `size` (rigid
// translation ⇒ all invariants preserved; gives the map a framed view and Lane B the real extent).
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const l of lots) {
minx = Math.min(minx, l.x - l.w); maxx = Math.max(maxx, l.x + l.w);
minz = Math.min(minz, l.z - l.d); maxz = Math.max(maxz, l.z + l.d);
}
for (const n of nodes) { minx = Math.min(minx, n.x); maxx = Math.max(maxx, n.x); minz = Math.min(minz, n.z); maxz = Math.max(maxz, n.z); }
const shx = -r2((minx + maxx) / 2), shz = -r2((minz + maxz) / 2);
for (const n of nodes) { n.x = r2(n.x + shx); n.z = r2(n.z + shz); }
for (const l of lots) { l.x = r2(l.x + shx); l.z = r2(l.z + shz); }
for (const b of blocks) b.poly = b.poly.map(p => [r2(p[0] + shx), r2(p[1] + shz)]);
for (const d of districts) { d.cx = r2(d.cx + shx); d.cz = r2(d.cz + shz); }
const M = 40;
const size = { w: Math.max(256, Math.ceil((maxx - minx) + 2 * M)), d: Math.max(256, Math.ceil((maxz - minz) + 2 * M)) };
if (opts.report) Object.assign(opts.report, norm); // caller-supplied log sink (selfcheck prints it)
return {
version: 1,
citySeed,
source: 'osm', // town key stays OFF the plan (keeps the Melbourne golden frozen); the
name: fx.town || 'Melbourne', // display name + the selector arg convey which town this is.
size,
districts,
streets: { nodes, edges },
blocks,
lots,
shops,
};
}
// The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration).
export function osmTownKeys() { return [...new Set([...Object.keys(OSM_TOWNS), ...Object.keys(TOWN_CACHES)])]; }