The real-map scout (ledger #6), A's half. Scope fence held: NO new plan features, NO golden moved (classic 0x3fa36874, gig 0xb1d48ea1, all three osm fixtures frozen). The scout proves the EXISTING ?plansrc=osm contract eats real geography. - The town-cache contract (published FIRST — E builds build_towns.py to it). plan_osm.js gains validateTownCache() (authoritative {ok,errors,warnings}), registerTownCache(), a generatePlanOSM(.., {cache}) seam, and TOWN_CACHE_SCHEMA/MIN_TOWN_SHOPS(6)/MAX_TOWN_SPAN_M(5km). Shape: {schema,key,town, source,license,attribution,center{lat,lon},shops[{id,name,type,lat,lon,suburb?}]}. Home declared: web/assets/towns/<key>.json (+ README.md full spec). Documented in CITY_SPEC v3.2 + LANE_A_NOTES R17. - plan_osm hardened: blank name -> registry label; the validator gates finite center/coords + a >=6-shop floor and warns on unknown-type(->opshop)/dup-id/span>5km. Proven vs thriftgod's real 2,828-shop city_source.json: boots a valid plan, 0 bad coords, 0 'undefined' names. - selfcheck: a committed synthetic-adversarial contract suite (valid-messy caches boot + pass structural/ gig; invalid reject; wide-spread warns; registry seam) + an auto-loader for web/assets/towns/*.json (validate -> suites -> pin per-town golden), empty and green until E lands caches. ALL GREEN 15277/15277. - v4 charter risk list (LANE_A_NOTES R17): the MEGA-STRIP (unbounded bbox -> 11.6 km strip; town selection is E's real lever, warned >5km); sparse compact towns (2 km inner-Canberra held 6 shops); osm facades clear (rows 54 m apart) so R16's bias is a no-op on real towns. E->A handshake: E drops a cache in web/assets/towns/, the selfcheck prints its golden, I pin it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
14 KiB
JavaScript
241 lines
14 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 } from '../core/registry.js';
|
||
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
|
||
|
||
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/1';
|
||
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)
|
||
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 && cache.schema !== TOWN_CACHE_SCHEMA) warnings.push(`schema '${cache.schema}' != '${TOWN_CACHE_SCHEMA}'`);
|
||
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)`);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
// ── project real shops → local metres; bin by latitude into avenue rows, order by longitude ──
|
||
const cosLat = Math.cos(fx.center.lat * Math.PI / 180);
|
||
const pts = fx.shops.map(s => ({
|
||
s, x: (s.lon - fx.center.lon) * EARTH_M * cosLat, z: (s.lat - fx.center.lat) * EARTH_M,
|
||
}));
|
||
const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z));
|
||
const span = (zMax - zMin) || 1;
|
||
const rowsArr = Array.from({ length: ROWS }, () => []);
|
||
for (const p of pts) {
|
||
const row = Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)));
|
||
rowsArr[row].push(p);
|
||
}
|
||
|
||
const district = addDistrict('mainstreet', 0, 0);
|
||
// spine: one main street running S→N through the origin, a node per (occupied) row
|
||
const spineNodes = [];
|
||
const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length);
|
||
usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); });
|
||
const spineEdges = [];
|
||
for (let k = 0; k < spineNodes.length - 1; k++) spineEdges.push(addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main'));
|
||
|
||
// ── each row → an east–west avenue off the spine; its shops march into non-overlapping lots ──
|
||
usedRows.forEach((o, k) => {
|
||
const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak
|
||
const zPos = o.zPos;
|
||
// frontages from each shop's registry footprint (deterministic mid-band, no rng needed here)
|
||
const fr = rowShops.map(p => { const fp = SHOP_TYPES[p.s.type]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; });
|
||
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
|
||
const west = addNode(0, zPos), east = addNode(avLen, zPos);
|
||
const avEdge = addEdge(west.id, east.id, AV_W, 'side');
|
||
// block = the frontage strip on the south side of the avenue
|
||
const half = AV_W / 2, sd = DEPTH + 2;
|
||
const block = addBlock(district, 'mainstreet',
|
||
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
|
||
const ry = Math.atan2(0, -1); // outward normal (street→lot) points south (0,-1) ⇒ facade faces +z (north) to the avenue
|
||
let t = SPINE_CLEAR;
|
||
rowShops.forEach((p, j) => {
|
||
const f = fr[j];
|
||
const cx = t + f / 2;
|
||
const cz = zPos - (half + DEPTH / 2);
|
||
const lot = addLot(block, cx, cz, f, DEPTH, ry, avEdge, 'shop');
|
||
addShop(lot, p.s);
|
||
t += f + GAP;
|
||
});
|
||
});
|
||
|
||
// ── 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)])]; }
|