// 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_FIXTURE } 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(); } export function generatePlanOSM(citySeed) { citySeed = (citySeed >>> 0); const fx = OSM_FIXTURE; 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'; 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); close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name), 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'); 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)) }; return { version: 1, citySeed, source: 'osm', name: fx.town || 'Melbourne', size, districts, streets: { nodes, edges }, blocks, lots, shops, }; }