// PROCITY CityGen — THE ADDRESS LAYER (Lane A, ROUND39 item 39.1). `createAddresses(plan, cache)`. // // PURE. No THREE, no fetch, no DOM, no plan mutation, no module state. In: a CityPlan (+ the town // cache it was lifted from, or null). Out: a small JSON-safe query object. Same laws as the rest of // citygen — deterministic, and it CANNOT move a golden because it never writes to `plan`. // // ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────── // 17,835 of the 19,132 road ways in the shipped caches carry a `name` (93.2% — re-measured this round, // exact to the digit). `plan_osm.js:365` builds each way as `rawWays.push({ kind, pl })` and DISCARDS // `rd.name`. Every real street in 23 Australian towns has been sitting in this repo with its name on // it and the game has never said one out loud. This module says them, without changing plan output. // // ── THE ONE CONSUMER CONTRACT (this is the point of the module — read this before consuming) ───── // // createAddresses(plan, cache|null, opts?) → { // streetOf(edgeId) → "Templeton Street" | null // real street name, or null. Never a guess. // localityOf(shopId) → { shopId, street, district, block, side, label } | null // cohort(predicate) → shopId[] // predicate(locality) → bool; ids ascending // streets() → [{ name, edges:[edgeId], shops:[shopId] }] // sorted by name // stats() → { … } // the measurement, so a gate reads numbers instead of a claim // } // // TWO SUPPLIERS, ONE FIELD. `label` is the string a consumer PRINTS, and it is filled by a different // supplier on each kind of town: // · a real town (a cache with named ways) → the street name: "Templeton Street" // · the synthetic town (no cache, 22 edges) → the district phrase: "the market end", "the arcade", // "the backstreets" — from `district.kind` + the block's sector inside that district. // **CONSUMERS MUST NEVER BRANCH ON TOWN TYPE.** Do not test `plan.source`, do not test for a cache, // do not special-case the synthetic. Print `label`; group by `block`; ask `street` only when you // specifically need a *name* and can honestly handle null. That constraint is the whole design: the // moment a consumer branches, every feature built on this has to be written twice and the synthetic // half rots. (`street` is null on the synthetic town by construction, and null on the ~2% of real-town // shops whose frontage resolves to no named way. `label` is null exactly when nothing honest can be // said — a real-town shop with no resolvable street. Handle null once, on both town types.) // // A WRONG STREET NAME IS WORSE THAN NO STREET NAME. The player navigates by this. So the resolver is // deliberately conservative: see THE RESOLUTION RULE below. Unresolved is `null`, loudly, every time. const isNum = v => typeof v === 'number' && Number.isFinite(v); const EARTH_M = 111320; // metres per degree latitude — plan_osm.js:24's equirectangular convention const cents = v => Math.round(v * 100); // plan_osm rounds every coord to 2dp (r2); work on that integer lattice // ── THE TOLERANCE ─────────────────────────────────────────────────────────────────────────────── // 8 m, as briefed — but the number that decided it is not the coverage curve, it is the CONTROL. // Plan nodes ARE snapped projected way points (plan_osm.js:361-372, SNAP=3), so a plan edge lies ON // its way: median sample→way distance is 0.03–0.90 m across the 23 caches (worst katoomba). The real // bound the tolerance has to clear is Douglas–Peucker chord error (EPS = 6 m, plan_osm.js:353), not // the median. // // Measured, whole corpus, corpus shops that get a street name: // 2 m → 85.2% 4 m → 94.8% 6 m → 97.4% 8 m → 97.8% 12 m → 99.1% 16 m → 99.2% // and the share of resolved edges where TWO different names both qualified at all five samples: // 2 m → 0.23% 4 m → 3.21% 6 m → 7.27% 8 m → 10.96% 12 m → 16.59% 16 m → 20.68% // // THE FINDING THAT ACTUALLY SETS THE KNOB — checked against the independent way-membership control // (see LANE_A_NOTES §39): over the 884 shop-bearing edges, the number that resolve to the SAME name // the control derives is **857 at 6 m and 857 at 8, 12, 16 and 24 m**. Correctness SATURATES AT 6 m. // Every metre past 6 buys exactly one thing: a name attached to a way OSM left unnamed (0 such edges // at 6 m, 4 at 8 m, 12 at 12 m, 14 at 24 m). It can never buy a correction. So raising this knob to // chase coverage is always borrowing a neighbour's name, and should be argued as that, never as // accuracy. 8 m keeps the four borrowings — all arcade footways inside a named mall (bowral's // "Corbett Plaza" ×2, fremantle's "High Street" ×2), where the borrowed name is what a person would // actually say. `opts.tolerance: 6` is the strictly-conservative setting at identical correctness. export const STREET_TOLERANCE_M = 8; // ── THE RESOLUTION RULE (the reason this is not "nearest way to the midpoint") ────────────────── // The brief says "nearest named way to the edge midpoint". A midpoint alone cannot tell a street from // the street that CROSSES it: at an intersection both are ~0 m away, and picking either by a hair is // exactly the wrong-name failure the player would navigate by. So a name must be within tolerance at // FIVE samples spread along the edge, not at one point. A crossing street is close at one sample and // far at the rest; the edge's own street is ~0 m at all five. Endpoints are excluded (t ∈ [0.15,0.85]) // because they ARE the junctions. Among names that qualify at every sample, the smallest worst-case // distance wins; a tie inside 0.25 m (genuinely coincident ways) is resolved by name, deterministically. const SAMPLE_TS = [0.15, 0.3, 0.5, 0.7, 0.85]; const DISTRICT_PHRASE = { mainstreet: 'the main street', market: 'the market end', arcade: 'the arcade', backstreets: 'the backstreets', warehouse: 'the warehouse fringe', residential: 'the residential streets', }; const SECTOR = ['east', 'north', 'west', 'south']; // index = quadrant of atan2, see sectorOf // Nearest point on segment AB to P, squared distance. (Same maths as selfcheck's nearestOnSeg.) function segDist2(px, pz, ax, az, bx, bz) { const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz; let t = L2 ? ((px - ax) * dx + (pz - az) * dz) / L2 : 0; t = t < 0 ? 0 : t > 1 ? 1 : t; const qx = px - (ax + dx * t), qz = pz - (az + dz * t); return qx * qx + qz * qz; } // Cardinal sector of a vector, as a word. Two opposite vectors always map to two opposite words, so // the two sides of any street get two distinct, stable tokens whatever its bearing. function sectorOf(dx, dz) { return Math.abs(dx) >= Math.abs(dz) ? (dx >= 0 ? 'east' : 'west') : (dz >= 0 ? 'north' : 'south'); } // ── THE SHIFT ─────────────────────────────────────────────────────────────────────────────────── // plan_osm.js:502-506 centres the imported town on the origin with a rigid translation // (`shx`,`shz`) applied AFTER the graph is built, so: plan node = r2(projected way point) + shift, // exactly, on the 2dp lattice. To compare a plan edge against a cache way we must undo it. // // It is recovered EXACTLY, not estimated. Take one plan node; every (node − way point) difference is // a candidate; keep only candidates under which further plan nodes ALSO land exactly on way points. // Two or three nodes collapse ~25,000 candidates to one, because a real road network has no // centimetre-exact translational symmetry. Measured: **exact on 23 of 23 caches**, no tuning, and it // returns null rather than a guess when it cannot prove an answer. // // This is deliberately NOT the centroid-align + nearest-waypoint vote the synthesis describes. I // implemented that first and it landed on the WRONG shift on 5 of the 23 towns (braddon, fremantle, // hobart, northbridge, westend) — the cull of shopless islands (plan_osm.js:407-414) drags the plan's // centroid off the cache's, the initial alignment is then tens of metres out, and the vote converges // confidently onto a wrong offset. A wrong shift is not a degraded answer: it renames EVERY street in // the town. A tuned vote can no doubt be made to work; an exact recovery has nothing to tune, so it // is the one I shipped. `opts.shift` (e.g. `report.shift` from `generatePlanOSM(seed, town, {report})`) // short-circuits it and is CROSS-CHECKED against it — `stats().shiftCheck` reports 'agree'/'DISAGREE'. function recoverShift(plan, projPointKeys, sampleNodes) { if (!sampleNodes.length || !projPointKeys.size) return null; for (let anchor = 0; anchor < Math.min(4, sampleNodes.length); anchor++) { const n0 = sampleNodes[anchor]; let cands = []; for (const k of projPointKeys) { const c = k.indexOf(','); cands.push([cents(n0.x) - +k.slice(0, c), cents(n0.z) - +k.slice(c + 1)]); } for (let i = 0; i < sampleNodes.length && cands.length > 1; i++) { if (i === anchor) continue; const n = sampleNodes[i], nx = cents(n.x), nz = cents(n.z); const kept = cands.filter(([sx, sz]) => projPointKeys.has(`${nx - sx},${nz - sz}`)); if (!kept.length) break; // this anchor was not itself a way point cands = kept; } if (cands.length === 1) return { shx: cands[0][0] / 100, shz: cands[0][1] / 100, source: 'recovered' }; } return null; } export function createAddresses(plan, cache = null, opts = {}) { const TOL = isNum(opts.tolerance) && opts.tolerance > 0 ? opts.tolerance : STREET_TOLERANCE_M; const TOL2 = TOL * TOL; const edges = (plan && plan.streets && Array.isArray(plan.streets.edges)) ? plan.streets.edges : []; const nodes = (plan && plan.streets && Array.isArray(plan.streets.nodes)) ? plan.streets.nodes : []; const lots = (plan && Array.isArray(plan.lots)) ? plan.lots : []; const shops = (plan && Array.isArray(plan.shops)) ? plan.shops : []; const blocks = (plan && Array.isArray(plan.blocks)) ? plan.blocks : []; const districts = (plan && Array.isArray(plan.districts)) ? plan.districts : []; const nodeById = new Map(nodes.map(n => [n.id, n])); const edgeById = new Map(edges.map(e => [e.id, e])); const lotById = new Map(lots.map(l => [l.id, l])); const blockById = new Map(blocks.map(b => [b.id, b])); const districtById = new Map(districts.map(d => [d.id, d])); const roads = (cache && Array.isArray(cache.roads)) ? cache.roads : []; const hasCentre = !!(cache && cache.center && isNum(cache.center.lat) && isNum(cache.center.lon)); const namedRoads = hasCentre ? roads.filter(r => r && Array.isArray(r.pts) && r.pts.length >= 2 && typeof r.name === 'string' && r.name.trim()) : []; // ── SUPPLIER A: the real streets, from the cache's named ways ────────────────────────────────── const streetByEdge = new Map(); // edgeId → name let shift = null, shiftSource = 'none', shiftCheck = null; let resolveCandidatesSeen = 0, ambiguousEdges = 0; if (namedRoads.length) { const cosLat = Math.cos(cache.center.lat * Math.PI / 180); const projX = lon => (lon - cache.center.lon) * EARTH_M * cosLat; const projZ = lat => (lat - cache.center.lat) * EARTH_M; // every way point (named or not — a plan node may come from an unnamed way) on the 2dp lattice const projPointKeys = new Set(); for (const rd of roads) { if (!rd || !Array.isArray(rd.pts)) continue; for (const p of rd.pts) if (Array.isArray(p) && isNum(p[0]) && isNum(p[1])) { projPointKeys.add(`${cents(projX(p[1]))},${cents(projZ(p[0]))}`); } } // spread the sample across the node list so one bad island can't own the anchor set const step = Math.max(1, Math.floor(nodes.length / 12)); const sampleNodes = []; for (let i = 0; i < nodes.length && sampleNodes.length < 12; i += step) sampleNodes.push(nodes[i]); const given = opts.shift && isNum(opts.shift.shx) && isNum(opts.shift.shz) ? opts.shift : null; const rec = recoverShift(plan, projPointKeys, sampleNodes); if (given && rec) shiftCheck = (cents(given.shx) === cents(rec.shx) && cents(given.shz) === cents(rec.shz)) ? 'agree' : 'DISAGREE'; if (given) { shift = { shx: given.shx, shz: given.shz }; shiftSource = 'given'; } else if (rec) { shift = { shx: rec.shx, shz: rec.shz }; shiftSource = 'recovered'; } if (shift) { // segment grid over the named ways, in CACHE (projected) space const CELL = 32; const segs = []; // [ax, az, bx, bz, nameIdx] const names = [], nameIdx = new Map(); const grid = new Map(); for (const rd of namedRoads) { const nm = rd.name.trim(); let ni = nameIdx.get(nm); if (ni === undefined) { ni = names.length; names.push(nm); nameIdx.set(nm, ni); } const pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])) .map(p => [projX(p[1]), projZ(p[0])]); for (let i = 0; i + 1 < pl.length; i++) { const si = segs.length; segs.push([pl[i][0], pl[i][1], pl[i + 1][0], pl[i + 1][1], ni]); const x0 = Math.floor(Math.min(pl[i][0], pl[i + 1][0]) / CELL), x1 = Math.floor(Math.max(pl[i][0], pl[i + 1][0]) / CELL); const z0 = Math.floor(Math.min(pl[i][1], pl[i + 1][1]) / CELL), z1 = Math.floor(Math.max(pl[i][1], pl[i + 1][1]) / CELL); for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) { const k = `${cx},${cz}`; const b = grid.get(k); if (b) b.push(si); else grid.set(k, [si]); } } } // name → smallest squared distance within TOL of (x,z); absent = farther than TOL const nearNames = (x, z) => { const out = new Map(); const x0 = Math.floor((x - TOL) / CELL), x1 = Math.floor((x + TOL) / CELL); const z0 = Math.floor((z - TOL) / CELL), z1 = Math.floor((z + TOL) / CELL); for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) { const b = grid.get(`${cx},${cz}`); if (!b) continue; for (const si of b) { const s = segs[si]; const d2 = segDist2(x, z, s[0], s[1], s[2], s[3]); if (d2 > TOL2) continue; const cur = out.get(s[4]); if (cur === undefined || d2 < cur) out.set(s[4], d2); } } return out; }; for (const e of edges) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const ax = a.x - shift.shx, az = a.z - shift.shz, bx = b.x - shift.shx, bz = b.z - shift.shz; // intersect the qualifying-name sets across all five samples, carrying the worst distance let worst = null; for (let k = 0; k < SAMPLE_TS.length; k++) { const t = SAMPLE_TS[k]; const hit = nearNames(ax + (bx - ax) * t, az + (bz - az) * t); if (!hit.size) { worst = null; break; } if (worst === null) { worst = hit; continue; } const next = new Map(); for (const [ni, d2] of worst) { const d = hit.get(ni); if (d !== undefined) next.set(ni, d > d2 ? d : d2); } if (!next.size) { worst = null; break; } worst = next; } if (!worst || !worst.size) continue; resolveCandidatesSeen += worst.size; if (worst.size > 1) ambiguousEdges++; let bestNi = -1, bestD = Infinity; for (const [ni, d2] of worst) { const d = Math.sqrt(d2); if (d < bestD - 0.25 || (Math.abs(d - bestD) <= 0.25 && bestNi >= 0 && names[ni] < names[bestNi])) { bestD = Math.min(d, bestD); bestNi = ni; // ±0.25 m tie → alphabetical, deterministic } } if (bestNi >= 0) streetByEdge.set(e.id, names[bestNi]); } } } // ── SUPPLIER B: the synthetic town's districts ───────────────────────────────────────────────── // No cache, no named ways, 22 edges. The label comes from `district.kind` + the block's sector // inside that district, so it is a PLACE ("the market end") rather than a name. Same field. const blockLabel = new Map(); // blockId → label { const perDistrict = new Map(); for (const b of blocks) { const cen = b.poly && b.poly.length ? b.poly.reduce((a, p) => [a[0] + p[0] / b.poly.length, a[1] + p[1] / b.poly.length], [0, 0]) : [0, 0]; const arr = perDistrict.get(b.district); if (arr) arr.push([b, cen]); else perDistrict.set(b.district, [[b, cen]]); } for (const [did, arr] of perDistrict) { const d = districtById.get(did); const phrase = (d && DISTRICT_PHRASE[d.kind]) || 'the town'; if (arr.length === 1) { blockLabel.set(arr[0][0].id, phrase); continue; } const cx = arr.reduce((s, [, c]) => s + c[0], 0) / arr.length; const cz = arr.reduce((s, [, c]) => s + c[1], 0) / arr.length; for (const [b, c] of arr) blockLabel.set(b.id, `the ${sectorOf(c[0] - cx, c[1] - cz)} end of ${phrase}`); } } const supplier = streetByEdge.size ? 'ways' : 'district'; // ── the shop table ───────────────────────────────────────────────────────────────────────────── const localityByShop = new Map(); for (const sh of shops) { const lot = lotById.get(sh.lot); const block = lot ? blockById.get(lot.block) : null; const district = block ? districtById.get(block.district) : null; const street = lot && streetByEdge.has(lot.frontEdge) ? streetByEdge.get(lot.frontEdge) : null; // side of the street: the cardinal the lot sits on, off its frontEdge's centreline let side = null; if (lot) { const e = edgeById.get(lot.frontEdge); const a = e ? nodeById.get(e.a) : null, b = e ? nodeById.get(e.b) : null; if (a && b) { const dx = b.x - a.x, dz = b.z - a.z, L2 = dx * dx + dz * dz; let t = L2 ? ((lot.x - a.x) * dx + (lot.z - a.z) * dz) / L2 : 0; t = t < 0 ? 0 : t > 1 ? 1 : t; side = sectorOf(lot.x - (a.x + dx * t), lot.z - (a.z + dz * t)); } } localityByShop.set(sh.id, { shopId: sh.id, street, district: district ? district.kind : null, block: lot ? lot.block : null, side, label: supplier === 'ways' ? street : (lot ? (blockLabel.get(lot.block) || null) : null), }); } const streetIndex = new Map(); // name → { name, edges[], shops[] } for (const [eid, nm] of streetByEdge) { const rec = streetIndex.get(nm) || streetIndex.set(nm, { name: nm, edges: [], shops: [] }).get(nm); rec.edges.push(eid); } for (const loc of localityByShop.values()) if (loc.street) streetIndex.get(loc.street).shops.push(loc.shopId); for (const rec of streetIndex.values()) { rec.edges.sort((a, b) => a - b); rec.shops.sort((a, b) => a - b); } const resolvedShops = [...localityByShop.values()].filter(l => l.label !== null).length; const streetedShops = [...localityByShop.values()].filter(l => l.street !== null).length; return { streetOf: edgeId => (streetByEdge.has(edgeId) ? streetByEdge.get(edgeId) : null), localityOf: shopId => localityByShop.get(shopId) || null, cohort(predicate) { const out = []; for (const loc of localityByShop.values()) { try { if (predicate(loc)) out.push(loc.shopId); } catch { /* a throwing predicate selects nothing */ } } return out.sort((a, b) => a - b); }, streets: () => [...streetIndex.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), stats: () => ({ supplier, // 'ways' (a real town) | 'district' (the synthetic) tolerance: TOL, shift: shift ? { shx: shift.shx, shz: shift.shz } : null, shiftSource, // 'given' | 'recovered' | 'none' shiftCheck, // 'agree' | 'DISAGREE' | null (only one source present) namedWays: namedRoads.length, roads: roads.length, edges: edges.length, edgesNamed: streetByEdge.size, ambiguousEdges, // ≥2 names qualified at every sample (tie-broken) distinctStreets: streetIndex.size, shops: shops.length, shopsWithStreet: streetedShops, shopsLabelled: resolvedShops, candidatesPerResolvedEdge: streetByEdge.size ? resolveCandidatesSeen / streetByEdge.size : 0, }), }; }