From 4dc80c2146813dcd2b4759feb4736864b1224266 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 15:54:19 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20round=2018:=20town-cache=20schema=20?= =?UTF-8?q?v2=20(roads[])=20=E2=80=94=20published=20FIRST=20(v4.0-alpha=20?= =?UTF-8?q?REAL=20ROADS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/assets/towns/README.md | 34 ++++++++++++++++++++++++++++++++++ web/js/citygen/plan_osm.js | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/web/assets/towns/README.md b/web/assets/towns/README.md index 29b0918..b781168 100644 --- a/web/assets/towns/README.md +++ b/web/assets/towns/README.md @@ -42,6 +42,40 @@ strip (the R17 mega-strip risk — a whole region of 2,918 shops became an 11.6 `type` should be a registry `SHOP_TYPE` (`record`/`opshop`/`toy`/`book`/`video`/`pawn`/`milkbar`/`dept`/`stall`); `build_towns.py` maps OSM `shop=*` tags to these (unknowns land on `opshop`). +## Schema v2 — `roads[]` (ROUND18, v4.0-alpha REAL ROADS) + +A **v2** cache adds an optional `roads[]` — the town's real OSM street geometry. When present, `plan_osm` +builds the CityPlan street graph from the **real roads** and seats each shop on its nearest real edge (its +real street, real order, real side) instead of marching shops onto synthetic parallel avenues. **`roads` +absent (or `schema` `procity-town-cache/1`) ⇒ the marched fallback** — every shipped v1 cache stays valid. + +```jsonc +{ + "schema": "procity-town-cache/2", + // … all v1 fields (center, shops, license, …) … + "roads": [ + { "id": 12345678, // optional (OSM way id) + "kind": "primary", // REQUIRED — the OSM highway=* class (see the map below) + "name": "Katoomba Street", // optional + "pts": [ [-33.7175, 150.3110], [-33.7140, 150.3112], [-33.7100, 150.3115] ] } // REQUIRED — ≥2 [lat,lon] + // … + ] +} +``` + +**Road validity** (a bad road is a hard error; a soft one is absorbed): +- each road is `{ kind: string, pts: [[lat,lon], …] }` with **≥ 2 finite `[lat,lon]` points** — else **rejected**. +- `< 2` points → the road is **dropped** (warning); an unmapped `kind` falls to `side` (warning). + +**`kind` → CityPlan edge kind** (`ROAD_KIND` in `plan_osm.js`; the lift then promotes a shop-dense `side` +road to the `main` spine): `motorway`/`trunk`/`primary`/`secondary` → `main`; `tertiary`/`unclassified`/ +`residential`/`living_street`/`road` → `side`; `service`/`track` → `lane`; `pedestrian`/`footway`/`path`/ +`steps`/`cycleway` → `arcade`. **Fidelity (charter risk #4, A's knob):** ship the geometry roughly as OSM +has it; `plan_osm` simplifies (Douglas–Peucker) — don't pre-decimate below ~1 point per 5 m. + +**Bound the roads to the town** (the bbox law): fetch `highway=*` within the same per-town bbox as the shops +so the graph is the town's streets, not a region's. + ## Provenance (Lane E) OSM data is **ODbL**. Ship attribution with the data: this `README`, a `SOURCES.md` (E's licence table), diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 2091d6e..98f31b0 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -48,9 +48,21 @@ function signOf(name) { // // `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 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 @@ -58,7 +70,7 @@ const isNum = v => typeof v === 'number' && Number.isFinite(v); 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}'`); + 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'); @@ -87,6 +99,24 @@ export function validateTownCache(cache) { 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 }; }