selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files). THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING. Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18". Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path, and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the premise itself. MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING: synthetic seed 20261990 0 toward / 681 away (dot = -1.0000, exactly anti-parallel) marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton 0 toward, all away real-roads katoomba 0/72 <- B's number, reproduced 637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted ?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has been a row of back walls since v1. After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS -- a strip of shopfronts facing the road. FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT: plan.js:122 synthetic strip "facade faces back down the outward normal, to the street" <- it didn't plan.js:244 market row "the facade then faces -(outward) = +x" <- it didn't plan_osm:330 marched avenue "facade faces +z (north) to the avenue" <- due south plan_osm:443 real roads B's proven one-liner (B measured it, then reverted it -- A's file) All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72. THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T. Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is anti-drift machinery, not a defect preservative. 51 pins in this commit: classic/synthetic 0x3fa36874 -> 0x5f76e76 gig 0xb1d48ea1 -> 0xec7a2d39 fixtures melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4 23 real towns base + gig (46) Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment as a ONE-TIME, ruling-gated precedent -- from this hash forward the law reads as it always did. A SECOND BUG THE FIX EXPOSED (fixed here, counted). buildRealRoads seated blocks at KERB = 4 m from the CENTRELINE, but a `main` carriageway is 10 m wide -- its kerb is at 5 m. EVERY MAIN-STREET BUILDING HAS STOOD 1 m INSIDE THE ROAD SINCE R18. The footprint never moved; only which face is the front -- so it was invisible until the shopfront turned around and its poster landed in the traffic lane (-1.07 m clearance, 6 real failures). Blocks now seat behind the kerb per A's own published corridor law (roadWidth/2 + FOOTPATH). Pack drops 6.4% -> 6.6%, counted, not hidden. THE CROSS-CONVENTION GATE -- built to F's lesson, not to a spec line. F proved that measuring lots against CITY_SPEC:71's own convention is SELF-AGREEMENT: 72/72 "facing" over a town of blank walls. So this gate restates nothing. It replicates LANE B'S OWN maths from buildings.js (toWorld(lot, 0, y, d/2 + 0.02)) and asks a physical question neither spec line can fudge: does the quad the RENDERER actually draws land nearer the street than the lot centre? It fires if A's ry sign drifts OR if B's zf sign drifts. It would have caught this on day one of v1. CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention is documented separately as the different thing it is, with the explicit rule -- never assert a lot's facing against a spec line, assert it against the geometry the renderer draws. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
528 lines
37 KiB
JavaScript
528 lines
37 KiB
JavaScript
// 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, roadWidth } 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)
|
||
|
||
// ── 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/<godverseShopId>/`". 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/<id>/`); 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)`);
|
||
// RULING 2 (ROUND25, confirmed as law): a godverse cache is deliberately MIXED — a keyed GODVERSE
|
||
// layer (census shops + injected shops like Monster Robot) PLUS an inherited OSM texture layer that
|
||
// is legitimately unkeyed. G measured that the texture layer is load-bearing, not cosmetic:
|
||
// census-only Red Hill shipped a 306 m spacing warn and a poster inside a kerb; with the texture
|
||
// layer it passes. So "shops without an id" is the DESIGN, and my R24 warn (which counted every
|
||
// unkeyed shop as a gap) was asserting the design away — it fired 54/72 on a healthy town.
|
||
//
|
||
// What is still worth saying: a cache that CLAIMS godverse provenance but carries NO identity at all
|
||
// has no godverse layer — it is an osm cache wearing the wrong `source`. That is the only honest
|
||
// requirement left at this layer, and it's a warn for the R24 reasons that still hold (the charter's
|
||
// determinism boundary: a town must never fail to load because its STOCK identity is absent; tier 0
|
||
// is the designed ladder). Missing = tier 0 (soft), duplicate = mis-stocked (hard) — different arms.
|
||
if (isGodverseCache(cache) && withGid === 0) {
|
||
warnings.push(`'${GODVERSE_SOURCE}' cache carries no godverseShopId on any of ${cache.shops.length} shops — the GODVERSE layer is empty (every shop falls to tier 0; is this really a godverse cache?)`);
|
||
}
|
||
// 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/<godverseShopId>/`) 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]]);
|
||
// ROUND27 THE FACADE FIX: the lots sit SOUTH of the avenue (z = zPos − half − DEPTH/2), so the
|
||
// facade must face +z (north) at it. `atan2(0,-1)` (= π) pointed local +Z at −z — due south, away.
|
||
// The comment was right; the code was 180° off. Measured, not assumed: the marched path was NOT
|
||
// clean (the ruling asked me to check rather than trust that nobody had noticed).
|
||
const ry = Math.atan2(0, 1); // facade (+Z) 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 FOOTPATH = 1.5; // ROUND27: kerb→shopfront. KERB alone is centreline-relative and sits INSIDE a main carriageway.
|
||
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;
|
||
// ROUND27 THE FACADE FIX (B's proven one-liner — B measured katoomba 0/72 → 72/72 and reverted it
|
||
// because this is Lane A's file). (nx,nz) is the OUTWARD normal on the shop's real side; the facade
|
||
// (local +Z, B's canon) must face back down it, at the road — so atan2(−nx, −nz), not atan2(nx, nz).
|
||
const nx = side * -uz, nz = side * ux, ry = Math.atan2(-nx, -nz); // outward normal on the shop's real side; facade (+Z) faces road
|
||
// ROUND27, exposed BY the facade fix: `KERB` (4 m) is measured from the CENTRELINE, but a `main`
|
||
// street's carriageway is 10 m wide — its kerb is at 5 m. So every main-street building has stood
|
||
// 1 m INSIDE the road since R18. It was invisible while the facade faced away (nothing was drawn or
|
||
// asserted on that side); the moment the shopfront turned around, its poster landed in the traffic
|
||
// lane (−1.07 m). The building never moved — only which face is the front. Seat blocks behind the
|
||
// kerb per A's own corridor law (`roadWidth/2` IS the kerb), plus a footpath to stand on.
|
||
const pe = edges.find(x => x.id === e.id);
|
||
const off = Math.max(KERB, roadWidth(pe) / 2 + FOOTPATH), 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)])]; }
|