// PROCITY CityGen — the OSM plan source (Lane A, round 6). Second producer behind the same // CityPlan contract as plan.js, so Lanes B–F 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 east–west 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/.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) // ── the spacing floor (ROUND23 ledger #5 — D's R22 thin-tail finding, made law) ────────────────── // MIN_TOWN_SHOPS counts shops; what makes a town READ ALIVE is clustering. D measured both 12-shop // towns in the pack and they disagree — that disagreement is the whole finding: // darwin_real ALIVE — shops sit on one strip; 1455 patronage checks → 18 visits. // toowoomba_real DEAD — shops scattered over a 4.6 km road network; 1417 checks → 0 visits, 0 finds. // "Not a shopping town — a road network with occasional shops." // Both pass MIN_TOWN_SHOPS. One has a high street; the other doesn't. So: WARN, never error — E curates // (re-bbox or retire, counted either way), the validator only flags. A flagged cache still boots a valid // district, which is why this can't be an error: toowoomba shipped green in the whole v4.0 pack. export const MAX_MEDIAN_SPACING_M = 150; // D's metric: for each shop, the distance to its NEAREST OTHER shop; take the median. Exported so every // lane measures the same thing (the R13-debt-#5 pattern — publish the law as a function, not a paragraph // each lane re-implements). Returns null for a cache with < 2 usable shops (nothing to space). // // SPACE WARNING — this is CACHE space (raw lat/lon), not PLAN space (post-lift metres), and the two do // NOT agree. The lift marches shops onto real edges at a regularised cadence, so it COMPRESSES dense // towns (darwin: 68.3 m cache → 27.2 m plan) while leaving sparse ones alone (toowoomba: 396.1 → 386.8). // D's notes quote PLAN figures (27 / 388); this function on the same towns returns 68.3 / 396.1. Both are // right — they measure different spaces. Cache space is the correct one HERE: the validator's job is to // flag a bad bbox at ingest, before any lift exists, and it is faithful exactly where the warn lives // (the sparse end: ballarat plan/cache 1.01, toowoomba 0.98). See LANE_A_NOTES §R23 for the full table. export function medianShopSpacing(cache) { const shops = (cache && Array.isArray(cache.shops) ? cache.shops : []).filter(s => s && isNum(s.lat) && isNum(s.lon)); if (shops.length < 2) return null; const lat0 = (cache.center && isNum(cache.center.lat)) ? cache.center.lat : shops[0].lat; const kx = 111320 * Math.cos(lat0 * Math.PI / 180), kz = 111320; // same equirectangular convention as the span check const nn = shops.map(s => { let best = Infinity; for (const o of shops) { if (o === s) continue; best = Math.min(best, Math.hypot((s.lon - o.lon) * kx, (s.lat - o.lat) * kz)); } return best; }); nn.sort((a, b) => a - b); const m = nn.length >> 1; return nn.length % 2 ? nn[m] : (nn[m - 1] + nn[m]) / 2; } // ── the identity field (ROUND24 ledger #2, with Lane G) ────────────────────────────────────────── // `shop.godverseShopId` — the OPTIONAL external key that says "this lot's stock lives at // `stock_godverse//`". Absent everywhere except godverse caches; schema stays v2 // (an optional field breaks no shipped cache, and the 22 osm caches are untouched by construction). // // IT IS NOT `shop.id`, AND IT IS NOT "THE CENSUS ID" — that assumption breaks on the one shop the // v5 alpha is about. Two id spaces ride this field, and G verified they are disjoint: // census shops → thriftgod `shop.id` (max 2992) — here godverseShopId does equal `id` // Monster Robot → dealgod store 3962749 — NOT in thriftgod's census at all // So it's an OPAQUE key: never derive it from `id`, never assume equality, never build a mapping // table (the R12 gigKey lesson — one key, zero mapping; G emits it, everyone else reads it). const GODVERSE_SOURCE = 'godverse+osm'; const isGodverseCache = cache => cache && cache.source === GODVERSE_SOURCE; // ── 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'; // 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 // 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)`); // godverseShopId (ROUND24 ledger #2) — the stock identity. Split by what is actually a hazard: // · MALFORMED → error. It names a path segment (`stock_godverse//`); a string "3962749" and // the number 3962749 are the same folder but different JSON, and that coercion drift is exactly // the species F caught in the atlas contract (licence/license). One truth: a positive integer. // · DUPLICATE → error. Two shops keyed to one atlas IS mis-stocking — charter risk #3 says // mismatches "fail-soft to tier 0, NEVER mis-stocked". This is the arm that earns its keep. // · MISSING on a godverse cache → WARN, not error. See the note below — this is a deliberate // departure from the round doc's "requires", and it is Fable's to overrule. let badGid = 0, dupGid = 0, withGid = 0; const gids = new Set(); for (const s of cache.shops) { if (!s || s.godverseShopId === undefined || s.godverseShopId === null) continue; withGid++; if (!Number.isSafeInteger(s.godverseShopId) || s.godverseShopId <= 0) { badGid++; continue; } if (gids.has(s.godverseShopId)) dupGid++; else gids.add(s.godverseShopId); } if (badGid) errors.push(`${badGid} shop(s) with a malformed godverseShopId (want a positive integer — it is a path segment, not a label)`); if (dupGid) errors.push(`${dupGid} duplicate godverseShopId(s) — two shops cannot share one stock atlas (mis-stock)`); // WHY MISSING IS A WARN AND NOT AN ERROR (ROUND24 ledger #2 says "requires"; recorded as a // departure, with reasons, per measurement-over-brief): // 1. The charter's determinism boundary: "world = seeded, frozen, gated. Stock = data tiers." // An error here inverts it — the TOWN would fail to load because its STOCK identity is absent. // Tier 0 must always boot; a shop with no id is a parody shop, which is the designed ladder. // 2. Charter risk #3 names the remedy for identity gaps: "fail-soft to tier 0, never mis-stocked." // Missing = tier 0 (soft). Duplicate = mis-stocked (hard). They are not the same failure. // 3. Sequencing: `newtown_godverse` carries 0 ids today and is G's to re-emit in wave 2 (their // namespace, and the census DB isn't on this machine). An error would red the tree for C and E // through all of wave 1 to convey what this warn already says. // The teeth live in selfcheck: PARTIAL coverage fails, and zero coverage prints an explicit SKIP // naming the town and the count (the vacuous-gate law) — so this cannot pass unnoticed. if (isGodverseCache(cache) && withGid < cache.shops.length) { warnings.push(`${cache.shops.length - withGid}/${cache.shops.length} shop(s) missing godverseShopId on a '${GODVERSE_SOURCE}' cache → those shops fall soft to tier 0 (parody); G re-emits`); } // 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)`); } // thin-tail warning (ROUND23 ledger #5): the span check catches a town that's too WIDE; this catches // one that's too SPARSE to read as a town at all — D's finding. Metadata only, like the span warn: // plan_osm still boots a valid district, it just has no high street to walk. const spacing = medianShopSpacing(cache); if (spacing !== null && spacing > MAX_MEDIAN_SPACING_M) { warnings.push(`median shop spacing ${Math.round(spacing)} m > ${MAX_MEDIAN_SPACING_M} m — shops are scattered, not a high street (re-bbox onto the retail strip, or retire the town)`); } } // 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; const shop = { id, lot: lotId, type, name: nm, sign: signOf(nm), seed, facadeSkin, storeys, hours: [open, close] }; // ROUND24 ledger #2: carry the stock identity THROUGH the lift. Without this the field would be a // cache-only decoration — the runtime reads `plan.shops[]`, never the cache, so F's per-shop `base` // (`stock_godverse//`) could never resolve. Conditional by design: absent ⇒ the key // is not written ⇒ `JSON.stringify` is byte-identical ⇒ every pinned town golden holds today, and // the field starts flowing the moment G re-emits (that re-emit IS the sanctioned golden move). if (Number.isSafeInteger(fixtureShop.godverseShopId) && fixtureShop.godverseShopId > 0) { shop.godverseShopId = fixtureShop.godverseShopId; } shops.push(shop); 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 (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() { // ROUND20 capacity (the density widening load-tested the R18 overflow fence): at 3–6× shops the // dominant drop cause was OVERFLOW — more shops assign to an edge-side than fit. Widened by tightening // the run: NODE_CLEAR 6→3 and a roads-only RGAP 2→0.5 (the shared marched `GAP` is untouched, so the // marched fixtures stay frozen). 0.5 m also reads truer — a real AU main street is continuous terrace // shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and // dropping a crowded shop beats teleporting it onto a street it isn't on. const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // 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 }; // every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density // accounting stays honest as real towns widen (ROUND20). const drop = (why, n = 1) => { norm.dropped = (norm.dropped || 0) + n; norm['dropped_' + why] = (norm['dropped_' + why] || 0) + n; }; 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 Douglas–Peucker — 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) { drop('stranded'); 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) { drop('overflow', list.length - k); break; } // edge-side full — overflow COUNTED 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 + RGAP; } } 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))) { drop('overlap'); 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)])]; }