PROCITY/web/js/citygen/plan_osm.js
m3ultra 4dc80c2146 Lane A round 18: town-cache schema v2 (roads[]) — published FIRST (v4.0-alpha REAL ROADS)
The half-day handshake so E's build_towns.py can fetch road geometry (ledger #1). Optional roads[]
on the town cache carries real OSM way geometry; plan_osm's graph lift (consuming it) follows in the
next commit. v1 caches (roads absent) stay valid — the marched fallback — so nothing shipped regresses.

- validateTownCache accepts schema procity-town-cache/1 AND /2; when roads[] is present each road must be
  { kind:string, pts:[[lat,lon],…] } with >=2 finite points (else rejected); <2 pts dropped, unmapped
  highway kind → 'side' (warnings). ROAD_KIND maps OSM highway=* → CityPlan edge kind (main/side/lane/arcade).
- Full v2 contract in web/assets/towns/README.md (the doc E builds to): shape, road validity, the kind map,
  the fidelity knob (charter risk #4), and the bbox-bound-the-roads rule.
- E's 5 existing v1 caches re-validate clean; selfcheck ALL GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:54:19 +10:00

271 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// PROCITY CityGen — the OSM plan source (Lane A, round 6). Second producer behind the same
// CityPlan contract as plan.js, so Lanes BF consume it unchanged. Selected via ?plansrc=osm
// (default stays 'synthetic', byte-identical — see index.js `generatePlanFor`).
//
// Input: a CHECKED-IN fixture of real inner-Melbourne secondhand/charity/music/book/toy/antique
// shops (osm_fixture.js, extracted from thriftgod's Overpass cache). ZERO network at runtime.
//
// How real data drives the plan (documented simplification — the honest "cut"): a shop's real
// LATITUDE picks which eastwest avenue it lands on, and its real LONGITUDE sets its order along
// that avenue. Avenue spacing and per-lot frontage are regularized (marched) so the result is a
// clean, non-overlapping, valid CityPlan — the geography is real in row+order, stylized in metres.
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js';
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
const clampHour = h => Math.max(0, Math.min(23, h | 0));
const LATE_HOUR = 22; // matches plan.js: exactly one shop closes ≥ this
const EARTH_M = 111320; // metres per degree latitude (equirectangular)
const ROWS = 8; // latitude bands → avenues
const ROW_DZ = 54; // metres between avenues (> avenue depth ⇒ blocks disjoint)
const AV_W = 12, SPINE_W = 24; // corridor widths
const SPINE_CLEAR = 16; // lots start this far east of the spine (no lot on the road)
const GAP = 2, DEPTH = 14; // lot gap + depth along/into the avenue
// Short signboard form from a real shop name: drop a leading "The", take words up to ~15 chars.
function signOf(name) {
const words = String(name).replace(/^the\s+/i, '').split(/\s+/);
let s = '';
for (const w of words) { if ((s + ' ' + w).trim().length > 15) break; s = (s + ' ' + w).trim(); }
return (s || String(name).slice(0, 15)).toUpperCase();
}
// ── the TOWN-CACHE CONTRACT (ROUND17 ledger #6, Lane A owns it) ─────────────────────────────────────
// What `plan_osm` accepts. E's pipeline (build_towns.py) produces one JSON per real town under
// web/assets/towns/<key>.json in THIS shape; plan_osm marches it exactly like the checked-in fixtures —
// the scout proves the EXISTING contract eats real data, no new plan fields, no new geometry.
//
// { schema:"procity-town-cache/1", key, town, source:"osm",
// license, attribution, // ODbL — REQUIRED on shipped real caches (+ SOURCES.md, E)
// center:{lat,lon}, // REQUIRED — equirectangular projection origin
// bbox?, counts?, fetchedAt?, // optional provenance/metadata (geometry ignores these)
// shops:[ { id, name, type, lat, lon, suburb? } ] } // REQUIRED, >= MIN_TOWN_SHOPS
//
// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed —
// same as the fixtures). A blank name defaults to the type label. `suburb` is optional.
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/2'; // v2 (ROUND18) adds optional roads[]
const ACCEPTED_SCHEMAS = new Set(['procity-town-cache/1', 'procity-town-cache/2']); // v1 stays valid → marched
export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare
export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note)
// ── roads[] (schema v2, ROUND18) ─────────────────────────────────────────────────────────────────
// OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road
// dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'.
export const ROAD_KIND = {
motorway: 'main', trunk: 'main', primary: 'main', secondary: 'main',
tertiary: 'side', unclassified: 'side', residential: 'side', living_street: 'side', road: 'side',
service: 'lane', track: 'lane',
pedestrian: 'arcade', footway: 'arcade', path: 'arcade', steps: 'arcade', cycleway: 'arcade',
};
export const roadEdgeKind = hwy => ROAD_KIND[hwy] || 'side';
const isNum = v => typeof v === 'number' && Number.isFinite(v);
// Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not
// boot a valid district; warnings are absorbed (remapped type, defaulted name, missing licence).
export function validateTownCache(cache) {
const errors = [], warnings = [];
if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings };
if (cache.schema && !ACCEPTED_SCHEMAS.has(cache.schema)) warnings.push(`unknown schema '${cache.schema}' (want one of ${[...ACCEPTED_SCHEMAS].join(', ')})`);
const c = cache.center;
if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers');
if (!Array.isArray(cache.shops)) errors.push('shops must be an array');
else {
if (cache.shops.length < MIN_TOWN_SHOPS) errors.push(`>= ${MIN_TOWN_SHOPS} shops required (got ${cache.shops.length})`);
let badCoord = 0, blankName = 0, unknownType = 0, dupId = 0; const ids = new Set();
for (const s of cache.shops) {
if (!s || !isNum(s.lat) || !isNum(s.lon)) badCoord++;
if (!s || !(typeof s.name === 'string' && s.name.trim())) blankName++;
if (!s || !SHOP_TYPES[s.type]) unknownType++;
if (s) { if (ids.has(s.id)) dupId++; else ids.add(s.id); }
}
if (badCoord) errors.push(`${badCoord} shop(s) with non-finite lat/lon`);
if (blankName) warnings.push(`${blankName} blank shop name(s) → defaulted to type label`);
if (unknownType) warnings.push(`${unknownType} unknown shop type(s) → remapped to opshop`);
if (dupId) warnings.push(`${dupId} duplicate shop id(s)`);
// wide-spread warning: an over-broad Overpass bbox marches into an unusable multi-km strip (the R17
// real-data risk). Metadata only — plan_osm still boots a valid plan; it just isn't a coherent town.
let mnLat = Infinity, mxLat = -Infinity, mnLon = Infinity, mxLon = -Infinity;
for (const s of cache.shops) if (s && isNum(s.lat) && isNum(s.lon)) {
mnLat = Math.min(mnLat, s.lat); mxLat = Math.max(mxLat, s.lat);
mnLon = Math.min(mnLon, s.lon); mxLon = Math.max(mxLon, s.lon);
}
if (mxLat > mnLat) {
const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180);
if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`);
}
}
// v2: optional roads[] — simplified OSM ways. Each is `{ kind, pts:[[lat,lon],…] }` (id/name optional);
// `kind` is the OSM highway=* class. Absent (or a v1 cache) ⇒ the marched-avenue fallback, so nothing
// shipped regresses. A road needs ≥2 finite points; <2 is dropped, an unmapped kind falls to 'side'.
if (cache.roads !== undefined) {
if (!Array.isArray(cache.roads)) errors.push('roads must be an array (when present)');
else {
let badRoad = 0, shortRoad = 0, unknownKind = 0;
for (const rd of cache.roads) {
if (!rd || typeof rd.kind !== 'string' || !Array.isArray(rd.pts)) { badRoad++; continue; }
if (rd.pts.length < 2) { shortRoad++; continue; }
if (rd.pts.some(p => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1]))) badRoad++;
if (!ROAD_KIND[rd.kind]) unknownKind++;
}
if (badRoad) errors.push(`${badRoad} malformed road(s) — need { kind:string, pts:[[lat,lon],…] }`);
if (shortRoad) warnings.push(`${shortRoad} road(s) with < 2 points → dropped`);
if (unknownKind) warnings.push(`${unknownKind} road(s) with an unmapped highway kind → 'side'`);
}
}
if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)');
return { ok: errors.length === 0, errors, warnings };
}
// A runtime registry of validated town caches (E's real caches; the selfcheck + shell register them).
// generatePlanOSM resolves a town from opts.cache, then this registry, then the checked-in fixtures.
const TOWN_CACHES = {};
export function registerTownCache(key, cache) {
const v = validateTownCache(cache);
if (!v.ok) throw new Error(`town cache '${key}' invalid: ${v.errors.join('; ')}`);
TOWN_CACHES[key] = cache;
return v;
}
export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0);
const fx = opts.cache || TOWN_CACHES[town] || OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN];
// Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data
// "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan.
const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' };
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
const addBlock = (district, kind, poly) => { const id = bid++; blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) }); return id; };
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
const id = lid++;
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
return id;
};
// Create a shop record from a real fixture entry (real name; seeded facade/hours/storeys).
function addShop(lotId, fixtureShop) {
const id = sid++;
const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop';
if (type !== fixtureShop.type) norm.typesRemapped++; // unknown OSM kind → opshop (normalization)
const reg = SHOP_TYPES[type];
const seed = seedFor(citySeed, 'osmshop', fixtureShop.id);
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
const storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
let open = reg.hours.open, close = reg.hours.close;
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
// real OSM data has the odd unnamed shop — default a blank name to the registry label so the sign
// never renders "undefined" (build_towns.py filters most, but plan_osm must not trust its input).
const nm = (typeof fixtureShop.name === 'string' && fixtureShop.name.trim()) ? fixtureShop.name : (reg.label || type);
if (nm !== fixtureShop.name) norm.namesDefaulted = (norm.namesDefaulted || 0) + 1;
shops.push({ id, lot: lotId, type, name: nm, sign: signOf(nm),
seed, facadeSkin, storeys, hours: [open, close] });
return id;
}
// ── 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 eastwest 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)])]; }