Compare commits
8 Commits
cf1334fc73
...
55d5c634d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d5c634d9 | ||
|
|
db33faa628 | ||
|
|
d4fcf68860 | ||
|
|
10db373e67 | ||
|
|
dbb52c2390 | ||
|
|
c7525a682a | ||
|
|
a78dc76f84 | ||
|
|
4dc80c2146 |
@ -3,7 +3,37 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-16 (round 17) · owner: PROCITY-C · reviewer: Fable
|
||||
Last updated: 2026-07-16 (round 18) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-16 (round 18, v4.0-alpha REAL ROADS) — interior spot-soak of katoomba_real (ledger #6)
|
||||
|
||||
R18 §Lane C (small): enter every `katoomba_real` shop and assert valid interiors + no carve + budgets.
|
||||
Built the town plan the way the shell does — `registerTownCache(katoomba_real)` → `generatePlanFor(seed,
|
||||
'osm', {town, gigs:true})` (gigs on = the default boot) → `buildInterior` per shop (lot dims resolved from
|
||||
`plan.lots`). **All 20 doors PASS:**
|
||||
|
||||
| metric | result |
|
||||
|---|---|
|
||||
| shops entered | **20 / 20 built** |
|
||||
| throws · carves · path-fails · over-budget | **0 · 0 · 0 · 0** |
|
||||
| worst draws | **153** (shop 16, opshop/hall) ≤ 350 |
|
||||
| types resolved | opshop · book · record · video + venues pub · rsl · band_room |
|
||||
| gig venues (default boot) | 3 (ids 3/5/19) — all built valid gig interiors (stage + watchPoints) |
|
||||
| draw range | 108–153 |
|
||||
|
||||
**Degenerate-lot stress (preempts A's real-road re-seating):** 32 combos (8 extreme lots {2×3, 1×1, 3×30,
|
||||
40×4, 100×100, **0×0**, 4.2×4.1, 2.5×18} × 4 types incl. venues) → **0 throws · 0 carves · 0 path-fails**;
|
||||
every room floors at **≥4×4 m** (`computeDims Math.max(4, lot−0.6)`). So whatever frontage the real-graph
|
||||
lift seats shops on, the interior contract absorbs it.
|
||||
|
||||
**Caveat (honest):** audited against the CURRENT `katoomba_real.json` — schema v1, `roads: none`, so it boots
|
||||
the **marched fallback** (E's katoomba roads cache isn't committed yet; A's lift `a78dc76` is in). Interiors
|
||||
are **road-agnostic** (they consume lot dims + type + seed, never road geometry), and the degenerate-lot proof
|
||||
covers any lot the real graph produces, so the conclusion is stable. **Interior lane contributes 0 failures to
|
||||
the alpha's failure list.** A one-line re-run once E's `roads[]` cache lands (lot dims may shift with real
|
||||
frontage) — I'll re-confirm if asked, but the interior contract already absorbs the range. C otherwise on-call.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,10 +1,42 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 2–17 closed** (see below).
|
||||
v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs
|
||||
2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).*
|
||||
*Status: **all deliverables landed; self-check all-green (16810/16810); rounds 2–18 closed** (see below).
|
||||
v4.0-alpha REAL ROADS: cache schema v2 (roads[]) published first + the real-graph lift (OSM ways →
|
||||
CityPlan street graph) implemented, proven on a synthetic irregular graph. All osm/classic/gig goldens
|
||||
frozen; katoomba_real's real-roads golden pins when E's roads land (A→E→A).*
|
||||
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 + the real-graph lift (the epoch headline)
|
||||
|
||||
Lane A is the round's long pole (serial spine A→E→A). Two commits:
|
||||
|
||||
**Commit 1 — cache schema v2 (`roads[]`), published FIRST** (`4dc80c2`) so E's `build_towns.py` can fetch
|
||||
road geometry. A v2 cache adds `roads:[{kind,pts:[[lat,lon],…]}]` (`kind` = OSM `highway=*`);
|
||||
`validateTownCache` accepts schema `…/1` AND `…/2` (roads absent ⇒ marched fallback, nothing regresses);
|
||||
`ROAD_KIND` maps the class to a CityPlan edge kind. Full contract in `web/assets/towns/README.md`.
|
||||
|
||||
**Commit 2 — the real-graph lift** (`plan_osm.buildRealRoads`): real OSM ways → the CityPlan street graph,
|
||||
**same schema out** (B/C/D/F consume it unchanged). project → simplify (Douglas–Peucker ε≈6 m) → snap
|
||||
coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify `main`
|
||||
by OSM class + shop density → seat each shop on its nearest real edge (real order/side), marched +
|
||||
overlap-resolved. **Passes the full structuralSuite + gig district + determinism** on a committed synthetic
|
||||
irregular graph (main + two cross streets + intersection + acute angle + dead-end lane + curve). Robust on
|
||||
messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched fallback).
|
||||
|
||||
Frozen (the alpha changes only the roads path): classic `0x3fa36874`, gig `0xb1d48ea1`, osm melbourne/
|
||||
katoomba/silverton, + E's 4 trailing real caches pinned on v1 (bendigo/castlemaine/fremantle/newtown).
|
||||
`katoomba_real` stays UNPINNED — its golden re-pins to the real-ROADS output when E lands its roads (the
|
||||
A→E→A finalization). selfcheck ALL GREEN **16810/16810**; scaffold + consistency GREEN.
|
||||
|
||||
Fidelity decision (charter risk #4): real edge + real order + real side + regularised spacing — NOT
|
||||
pixel-exact (real shops overlap; no-overlap is load-bearing). Alpha failure list (LANE_A_NOTES §R18): (1)
|
||||
overflow drop on short edges (`norm.dropped`); (2) intersections = shared coordinates only (crossings
|
||||
without a shared OSM node → disconnected edges — a D sim-graph risk); (3) blocks are per-edge frontage
|
||||
strips not planar faces (a B ground-render audit item); (4) crowded-intersection overlap-resolve drop.
|
||||
|
||||
Handshake: schema v2 → E (done). "katoomba_real boots with real roads, golden pinned" → B/C/D fires when
|
||||
E's roads land (katoomba is v1/marched until then). B/C/D audit prep isn't blocked on me.
|
||||
|
||||
## Round 17 (2026-07-16) — v3.2 THE SCOUT: the town-cache contract + plan_osm hardened for real data
|
||||
|
||||
Lane A ran **first** (E's `build_towns.py` builds to the contract). Scope fence held: **no new plan
|
||||
|
||||
@ -368,6 +368,28 @@ auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig su
|
||||
**Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/
|
||||
`attribution` fields; raw Overpass responses cached so re-runs never re-fetch).
|
||||
|
||||
### Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS)
|
||||
|
||||
> The gap the scout left: the marched fallback lays real shops onto **synthetic** parallel avenues.
|
||||
> Schema v2 closes it — a cache carries the town's **real OSM street geometry** and `plan_osm` builds the
|
||||
> CityPlan street graph FROM it. **Opt-in by the cache** (a v2 cache with `roads[]` = real roads; a v1
|
||||
> cache, or `roads` absent, = the marched fallback), so default + classic boots are untouched by
|
||||
> construction (they never load a town cache) and every shipped cache stays valid.
|
||||
|
||||
A **v2** cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` — simplified OSM ways, `kind` the
|
||||
OSM `highway=*` class (`validateTownCache` accepts schema `…/1` and `…/2`; a road needs ≥2 finite points,
|
||||
`ROAD_KIND` maps the class to a CityPlan edge kind). The graph lift (`plan_osm.buildRealRoads`):
|
||||
1. **project** ways + shops to the local metre frame; **simplify** each way (Douglas–Peucker, ε≈6 m — the
|
||||
fidelity knob, charter risk #4);
|
||||
2. **snap** coincident points (≤3 m) to shared nodes = **real intersections**; straight-segment **edges**;
|
||||
3. **classify** `main`/`side` from the OSM class, promoting the shop-densest edge to the `main` spine if
|
||||
none came from OSM;
|
||||
4. **seat** each shop on its **nearest real edge** at its real order + real side, marched with regularised
|
||||
spacing (real shops overlap — pixel-exact positions can't satisfy the no-overlap invariant), a
|
||||
frontage-strip block per edge, then a deterministic **overlap-resolve** pass (crowded intersections drop
|
||||
the later-id lot). **Same CityPlan schema out** — B/C/D/F consume it unchanged by contract; the golden
|
||||
re-pins per the amendment law (`katoomba_real`'s when E's roads land; synthetic + fixtures untouched).
|
||||
|
||||
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
|
||||
|
||||
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
|
||||
|
||||
@ -2,6 +2,49 @@
|
||||
|
||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||||
|
||||
## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift
|
||||
|
||||
### → E: **schema v2 is live (committed first) — fetch roads to it**
|
||||
A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full
|
||||
contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid →
|
||||
marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox
|
||||
as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM
|
||||
already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds
|
||||
+ validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden.
|
||||
|
||||
### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph
|
||||
Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline:
|
||||
project → simplify (Douglas–Peucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real
|
||||
intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on
|
||||
its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full
|
||||
structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets
|
||||
(a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched
|
||||
fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run
|
||||
against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden).
|
||||
|
||||
### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list
|
||||
I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions,
|
||||
because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all
|
||||
assume it). Written down for the alpha (fixed or fenced):
|
||||
1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops
|
||||
drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep
|
||||
all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.*
|
||||
2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node
|
||||
become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph
|
||||
may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared
|
||||
coords at junctions (OSM convention) so most are fine.
|
||||
3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads
|
||||
these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a
|
||||
measurement (it's my lot geometry to fix, not yours).
|
||||
4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in
|
||||
`norm.dropped`.
|
||||
|
||||
### → B/C/D: handshake state
|
||||
`katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with
|
||||
real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the
|
||||
golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph
|
||||
lift + the failure list above tell you exactly what irregular geometry to point your audits at.
|
||||
|
||||
## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data
|
||||
|
||||
### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake)
|
||||
|
||||
@ -1,5 +1,58 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 18 (Fable's ROUND18 → Lane B) — irregular-geometry audit (ledger #4, v4.0-alpha REAL ROADS)
|
||||
|
||||
**The deliverable is the failure list.** Audited `katoomba_real` on E's real roads (schema v2, **484 OSM
|
||||
ways → a 799-edge / 872-node graph, 126 real intersections** (99 deg-3, 27 deg-4), edges **3.5 m–1303 m
|
||||
(28 short <12 m)**, span **~3945 × 4944 m**). E's roads cache landed mid-audit; verified fresh Chromium,
|
||||
cache-busted (the shell force-caches town JSON).
|
||||
|
||||
### Verdict: Lane B's streetscape is ROAD-AGNOSTIC — it consumes the CityPlan graph generically and survives
|
||||
Real Katoomba boots with **0 page errors**; every B system builds and renders on the irregular graph —
|
||||
ground strips, furniture (39 chunks / 148 trees), town-wide pools, tram, chunk streaming (`chunks.count`
|
||||
scales), buildings, wind. The real main street reads well (road + footpath + streetlamps + a working gig
|
||||
poster + citizens; `docs/shots` — the Screaming Utes bill renders on real bones). Like C's interiors, the
|
||||
streetscape needed **no code change** to eat real geometry. The real risks are elsewhere (below).
|
||||
|
||||
### FINDING → F (gate-blocking, proven one-line fix): the `BIG_CITY` adaptive heuristic misfires on real roads
|
||||
`index.html` sets `BIG_CITY = plan.shops.length > 120` → radius + shadow-pass. Real-roads towns invert
|
||||
the assumption: Katoomba has **20 shops (< 120)** but a **799-edge / 5 km** graph, so it stays small-town
|
||||
mode (**radius 3 + sun-shadow ON**) and streams **50–52 chunks** → the busiest venue block hits
|
||||
**~280k tris (clean load, settled) — BREACHES the 200k gate** (draws 150, still < 300).
|
||||
**Proven fix** — boot with `?r=2&shadows=0` (= what BIG_CITY mode would give): venue block drops to
|
||||
**84k tris day / 104k night, 20 chunks, 72 draws** — comfortably under. **Recommend**: extend the
|
||||
heuristic to the graph, e.g. `BIG_CITY = plan.shops.length > 120 || (plan.streets?.edges?.length||0) > 200`
|
||||
(catches Katoomba's 799; leaves synthetic Boolarra's 22 untouched). One line, index.html; the concept is
|
||||
B's adaptive-streaming (LANE_B_NOTES R3) extended from shop-count to edge-count. This is the charter's
|
||||
mega-strip/density risk in Lane B's budget.
|
||||
|
||||
### Tram (spine assumption — Fable's flagged risk): degrades gracefully, no crash — fence-ready, not fenced
|
||||
`spinePolyline` walks one main chain from a degree-1 node. On real Katoomba (232 main edges) it runs a
|
||||
**3527 m / 49-edge chain = 21% of mains** (the longest walkable chain — plausibly the real main road);
|
||||
the other mains form other chains with no tram. **No crash**, deterministic, ≤2 draws as ever. Arbitrary-
|
||||
but-functional for the alpha. A smarter route (pick the shop-adjacent chain) or a hard fence (tram off on
|
||||
real-roads towns) is a **v4.0-beta** call — no clean `plan.mode` marker is exposed to key a fence off, so
|
||||
I did **not** add untested fencing this round (alpha philosophy: surface + document). Fence-ready via the
|
||||
same `edges.length > 200` signal if beta wants it.
|
||||
|
||||
### Furniture / pools scale to real geometry (no break)
|
||||
Town-wide pools = **~4989 instances** on Katoomba (all in the ONE InstancedMesh → still **~1 draw**;
|
||||
built upfront, fogged beyond the streaming radius). Lamp cadence = ~4906 town-wide. **28 short edges
|
||||
(<12 m) get 0 lamps** (the `for(s=8; s<len-4; …)` loop — graceful sparsity, not a crash). No fix needed.
|
||||
|
||||
### Correction — the "disconnected graph" I first saw was a SYNTHETIC-FIXTURE artifact, NOT real Katoomba
|
||||
Stress-testing A's `selfcheck` roads fixture first (before E's real cache landed) produced a **fully
|
||||
disconnected graph** (4 edges / 8 nodes / all deg-1 / 0 intersections) — Douglas–Peucker flattened its
|
||||
near-collinear coordinates and dropped the junction vertices. **Real Katoomba does NOT have this**
|
||||
(126 intersections form correctly). So it's a fixture-degeneracy note, not a real-town finding — but
|
||||
worth flagging to A that DP can drop junctions on near-straight ways (charter risk #4, fidelity).
|
||||
|
||||
### For F's alpha gate (assertion list, all green on real Katoomba except the budget finding)
|
||||
boots 0-error · graph has real intersections (deg ≥ 3 present) · my modules all build · budget **needs
|
||||
the BIG_CITY fix** (else 280k > 200k at the venue block; with it, 104k) · tram no-crash · pools ~1 draw.
|
||||
|
||||
---
|
||||
|
||||
## Round 17 (Fable's ROUND17 → Lane B) — wind sway SHIPS (ledger #5, parked since R7)
|
||||
|
||||
**Verdict: SHIPPED, not killed.** The R7 park reason ("touches shared materials") was tested against
|
||||
|
||||
@ -6,6 +6,72 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 18 — v4.0-alpha REAL ROADS: citizens on real bones (ledger #5)
|
||||
|
||||
*PROCITY-D, 2026-07-16. The audit: does the citizen sim behave on real (irregular) street topology?
|
||||
E landed `katoomba_real`'s real road geometry (484 OSM ways → 872 nodes / 799 edges) mid-audit, so
|
||||
this is measured on the **real Katoomba graph**, not just a proxy. In-shell: fresh `CitizenSim` built
|
||||
on `plan.streets`, driven headless-of-render (rAF throttled). No source changes — the finding is the
|
||||
verdict + the failure list.*
|
||||
|
||||
### Verdict: the graph contract IS the contract — the sim needs ZERO changes
|
||||
The sim's graph-walk is topology-agnostic by construction (seeded random walk over nodes/edges, dead-end
|
||||
U-turn), so real geometry should just work. It does. Proven on a **crafted irregular town** (deg-4
|
||||
crossroads, 3 T-junctions, 5 dead-ends, a 30° acute fork, a cycle) AND the **real Katoomba graph**
|
||||
(7.2° acute junction, a 1303 m edge, 105 components):
|
||||
|
||||
| assertion | crafted irregular | **real Katoomba** |
|
||||
|---|---|---|
|
||||
| NaN/Inf ped positions | 0 / 240k | **0 / 1,095,500** |
|
||||
| off-footpath samples (ped beyond its own edge band) | 0 / 240k | **0** (maxDist 12.9 m = main offset) |
|
||||
| edges traversed | 11 / 11 | full main network |
|
||||
| peds pinned at dead-ends | 0 | 0 |
|
||||
| loiter fires | 154 ev | **174 ev** |
|
||||
| **bench-sit fires (R17)** | 47 ev | **60 ev** |
|
||||
| patronage fires (stream mode) | 165 visits | **24 visits, 4.3k inside-frames** |
|
||||
| chunk streaming over non-grid | 7 live rosters / 93 active | **13 chunks / 307 active** |
|
||||
| determinism (2 sims, same seed) | 0/120k pos mismatch; identity set visit-order-independent | **byte-identical rerun; 1167 sigs** |
|
||||
|
||||
Identity signature format is intact (`chunkKey#i:pedIndex:pvar:height:speed:edge:forward`), no `sit`
|
||||
term — my R17 `benchsit` stream doesn't perturb it. **Nothing in the sim needed touching for real roads.**
|
||||
|
||||
### The measured leak → FILED FOR A: the real-roads graph is fragmented
|
||||
`katoomba_real`'s street graph comes out in **105 disconnected components**. The main component holds
|
||||
**58% of street-metres (52.8 km) and 18 of 20 shops** — the core town is coherent — but **42% of street
|
||||
metres and 2 shops strand** on 102 islands (9 single nodes, 76 fragments of 2–4 nodes, 17 of 5–20).
|
||||
Live cost to citizens: **~10% of near-main crowd presence** lands on tiny fragments (aimless wandering,
|
||||
can't reach the main street or its shops); the 2 stranded shops get no main-crowd traffic.
|
||||
|
||||
- **Primary cause — expected, not a bug:** on real Katoomba the median fragment is **71.5 m** from the
|
||||
main net → **mostly peripheral / bbox-clipped roads and genuinely-separate service ways**, which is
|
||||
what an OSM extract in a bounded box always contains. The main town is coherent; this is the tail.
|
||||
- **Secondary mechanism (corroborated with Lane B):** A's `buildRealRoads` runs Douglas–Peucker **per-way
|
||||
before snapping**, so a junction point that is collinear on the through-way gets **dropped**, with no
|
||||
mid-edge split where one way's endpoint lands on another way's segment — a near-straight through-street
|
||||
with a mid-way junction can lose it → the crossing way strands. I hit this **three times** crafting the
|
||||
test town (fixed by modelling junctions as shared way-endpoints, as OSM does); **Lane B independently
|
||||
hit the same DP-collinear drop** on A's `selfcheck` fixture (LANE_B_NOTES §18). On *real* Katoomba this
|
||||
bites only the **9 of 85** tiny fragments that sit <10 m from the main network (20 within 30 m) — the
|
||||
ones a snap-tolerance / endpoint-split pass could legitimately join. A **fidelity knob** (charter risk #4).
|
||||
- **Reconciles with Lane B:** B verified real Katoomba's **126 intersections form correctly** (= my 27
|
||||
deg-4 + 99 deg-3 junction nodes) and concluded connectivity is fine — true *for the main component*. My
|
||||
add is the **global** measurement B didn't take: those 126 junctions live in a 480-node main component,
|
||||
but the full graph is still 105 components with 42 % of street-metres on islands. Both true, compatible.
|
||||
- **The sim degrades gracefully** — fragment peds just walk their island; **zero NaN, no crash**. This is
|
||||
A's graph-construction call (connect-tolerance / mid-edge split), not a sim change. A D-side mitigation
|
||||
(bias spawns toward the main component) is a **beta** item and needs A's connectivity decision first.
|
||||
|
||||
### Handshakes
|
||||
- **D → A:** the fragmentation measurement above (105 components, 42% street-metres + 2 shops stranded,
|
||||
root cause + the 9 close-fragments that could be joined). Your knob, your call.
|
||||
- **D → F (gate):** the sim is clean on the real graph — no D-side gate concern. Bench-sit + loiter +
|
||||
patronage + determinism all hold on `katoomba_real`. When you re-run the town matrix with the roads
|
||||
dimension, the citizen path is green from my side.
|
||||
- **Re-run note:** the whole audit is parameterized on `plan.streets`; it re-runs on any town's real
|
||||
graph in one call (used it on both the crafted town and real Katoomba this round).
|
||||
|
||||
---
|
||||
|
||||
## ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1)
|
||||
|
||||
*PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files
|
||||
|
||||
@ -1,5 +1,53 @@
|
||||
# LANE E — cross-lane notes
|
||||
|
||||
## Round 18 — v4.0-alpha REAL ROADS: the road-geometry fetch ⟨v4-alpha⟩
|
||||
|
||||
> Ledger #3 — E fetches the real street geometry; A's lift builds the CityPlan graph from it.
|
||||
> **`katoomba_real` is now schema v2 with 484 real streets** (the alpha town). → **Lane A: roads are
|
||||
> live; harden the lift, resolve the surfaced poster-clearance finding below, re-pin the golden.**
|
||||
|
||||
**The data.** `build_towns.py` extended with a bounded `way["highway"~…](bbox); out geom;` per town →
|
||||
raw cached in `_raw/<key>.roads.overpass.json` (fetch once, John's standing Overpass auth) → `roads[]`
|
||||
in A's v2 contract (`{id, kind, pts:[[lat,lon],…]}`; `kind` = raw OSM highway class, A's `ROAD_KIND`
|
||||
maps it, full geometry shipped for A's Douglas–Peucker). **katoomba_real: 484 streets** (residential
|
||||
243, tertiary 65, trunk 58, secondary 51, primary 32, unclassified 23, pedestrian 12 — real names:
|
||||
Bathurst Rd, Cliff Drive, Civic Place, …), 5344 points.
|
||||
|
||||
**Fidelity cut (charter risk #4 — flagged to A, who owns the knob).** I FETCH every `ROAD_KIND`-mapped
|
||||
tier (→ raw cache, full provenance / beta-ready) but EMIT only the **town street tier** — drivable
|
||||
roads + pedestrian malls — EXCLUDING `service`/`track`/`footway`/`path`/`steps`/`cycleway`. In
|
||||
Katoomba's 3 km bbox that excluded tier is **1461 of 2506 ways** (Blue-Mountains bushwalking tracks,
|
||||
sidewalks, stairs, driveways, parking aisles) — noise for a town street graph and a ~2500-edge blow-up.
|
||||
The full set is in the raw cache if A wants a tier back (one filter change). This is the honest cut.
|
||||
|
||||
**A's lift builds a real town from it** (verified, `generatePlanFor` seed 20261990): 484 streets →
|
||||
**872 nodes / 799 edges** (main 232, side 531, arcade 36) → 12 blocks, 20 lots, 20 shops, **3 venues
|
||||
(pub/band_room/rsl), 12 gigs**. `pickVenues` lands venues on the real graph — ledger #2 requirement met.
|
||||
|
||||
**⚠ FINDING for Lane A (the alpha's failure-list deliverable — charter risk #3).** On the real graph,
|
||||
**spine posters overhang a crossing carriageway**: selfcheck's kerb-clearance gate fails on 4 seeds
|
||||
(spine poster clears its cross-edge by **−1.6 to −2.5 m**; e.g. seed 20261990 poster 50 vs edge 58 =
|
||||
−2.42 m). Same CLASS as A's R15/R16 corner-poster fix, now on **real** cross-street geometry — a
|
||||
venue's frontage seats on the spine where a real side street crosses, so its poster reaches over that
|
||||
carriageway. **Owner: A** (`pickVenues` / `POSTER_CLEAR` on the real graph — the "A again" harden
|
||||
step). **My roads data is valid** — the v2 cache validates (roads accepted); the 4 failures are
|
||||
plan-level poster placement, not cache errors. Not a fidelity artifact either: the crossing streets are
|
||||
real primary town streets, so trimming roads wouldn't fix it. Golden settles after A's fix (pre-fix
|
||||
value `0x6014a8e6`). Handshake E→A: **katoomba roads live + this finding**.
|
||||
|
||||
**Other 4 towns** (data-only; A hardens against them, only katoomba gates): all now v2 —
|
||||
newtown_real 1086 roads, bendigo_real 1552, fremantle_real 622, castlemaine_real 397 (dense/regional
|
||||
towns → bigger graphs; real data, A's DP + density classification handle it). Roads are ODbL, raw
|
||||
committed, `SOURCES.md` updated. (Robustness: `build_towns.py` gained Overpass retry/backoff — the
|
||||
public API 504s under load.)
|
||||
|
||||
**The finding is SYSTEMATIC** — the spine-poster clearance fails identically on **all 5** real-road
|
||||
towns, every seed (23 checks, −0.9 to −4.7 m). That it reproduces across five different real street
|
||||
grids confirms it's **plan-level poster placement, not town data** → squarely A's (`pickVenues` /
|
||||
`POSTER_CLEAR`). Scoping holds: **only the `real/*` v2 path fails — synthetic + fixtures + classic +
|
||||
default boots are all green** (roads[] is the only changed path, per the alpha law). A's one fix
|
||||
generalizes to all five.
|
||||
|
||||
## Round 17 — v3.2 THE SCOUT: real town caches + the verify fix ⟨v3.2 amendment⟩
|
||||
|
||||
> The real-map scout for **v4 THE REAL MAP** (John's pick) — bounded data groundwork, no new game
|
||||
|
||||
95
docs/LANES/ROUND19_INSTRUCTIONS.md
Normal file
95
docs/LANES/ROUND19_INSTRUCTIONS.md
Normal file
@ -0,0 +1,95 @@
|
||||
# PROCITY — Round 19 lane instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-16 · **R18 verdict: the alpha did exactly what the charter asked — it surfaced the
|
||||
failure list. NOT yet tagged, and correctly so.** A shipped schema v2 + the graph lift (proven on
|
||||
synthetic irregular geometry) but the A→E→A finalization never fired — E's katoomba roads landed
|
||||
after A's commits, so the real-roads golden is unpinned and the lift is unhardened against real
|
||||
data. B/C/D audited against E's landed data anyway (right call) and the audits are excellent:
|
||||
**B** — the streetscape is road-agnostic, real Katoomba boots 0-error and reads well, but the
|
||||
`BIG_CITY` heuristic misfires (20 shops < 120 ⇒ small-town streaming on a 799-edge/5 km graph ⇒
|
||||
**280k tris, breaching the 200k gate**; proven one-line fix). **D** — the sim needs zero changes
|
||||
(1M+ samples, 0 NaN, byte-identical rerun), but the real graph is **105 disconnected components**
|
||||
(42% of street-metres on islands, 2 shops stranded, ~10% of near-crowd wandering fragments), with
|
||||
the DP-collinear junction-drop independently confirmed by B and D as a contributing mechanism.
|
||||
**C** — 20/20 real doors build valid interiors. **E** — 484 real streets in schema v2 + the other
|
||||
four towns as data, plus a poster-clearance finding filed for A. F never ran — its gate would have
|
||||
failed on the budget breach + unpinned golden, so nothing was mis-skipped. This is the alpha
|
||||
working as designed: surface, measure, file.
|
||||
|
||||
Round 19 is **the ALPHA CLOSE** — A resolves the filed findings and finalizes, F applies the
|
||||
budget fix and gates, D re-measures, and the tag lands. Tag **`v4.0-alpha`** at close.
|
||||
|
||||
## The ledger
|
||||
|
||||
1. **A — the finalization (the round's spine; everything hangs off you).**
|
||||
a. **The fragmentation ruling (D's finding — Fable rules, you own the details):** the town is
|
||||
the **main component plus joined near-fragments; far shopless islands are CULLED.**
|
||||
Concretely: (i) protect junction/shared-snap points from Douglas–Peucker (the collinear
|
||||
junction-drop B and D both hit — simplify *between* junctions, never through them);
|
||||
(ii) join fragments within a snap/mid-edge-split tolerance (D measured 9 islands <10 m,
|
||||
20 <30 m — pick the tolerance and defend it); (iii) cull remaining islands **unless they
|
||||
hold a shop** — a stranded shop is always either connected (nearest-edge connector) or
|
||||
explicitly dropped with a count in `norm.dropped`. Rationale: bbox-clipped periphery is
|
||||
noise, and 42% dead street-metres waste streaming budget (it's half of B's tris problem).
|
||||
b. **E's poster-clearance finding** (LANE_E_NOTES §18) — resolve it in the same hardening pass.
|
||||
c. **Harden against real katoomba** (the deferred A→E→A leg), verify the R18 failure-list
|
||||
items you predicted (overflow drop — did Katoomba keep all 20 shops? crossing-without-
|
||||
shared-node — did any survive into the graph?), **pin `katoomba_real`'s real-roads golden**,
|
||||
and fire the handshake.
|
||||
d. **Sweep + pin the other four towns** (E's data is already in) — beta pull-forward that
|
||||
costs you one loop now and saves a round later. Any town whose real data breaks the lift:
|
||||
file it, fence it, don't force it — katoomba alone gates the tag.
|
||||
2. **F — B's `BIG_CITY` fix + the alpha gate (last).**
|
||||
a. The one-liner is yours (`index.html`): extend the heuristic to the graph —
|
||||
`plan.shops.length > 120 || (plan.streets?.edges?.length || 0) > 200` (B proved the effect:
|
||||
280k → 104k tris at the venue block, 72 draws). Land it early so D/B re-measures see it.
|
||||
b. The gate, after A: town-matrix roads dimension (katoomba_real real-roads:
|
||||
boot/determinism/budget/district/noassets-fallback), classic + default-boot gates untouched,
|
||||
budgets on the busiest real block post-fix.
|
||||
c. **The money shot:** an uncommitted `docs/shots/v4_alpha/katoomba_mainstreet.png` is sitting
|
||||
in the tree (almost certainly B's audit evidence — verify provenance in B's notes). Reshoot
|
||||
deterministically via the tour harness for the official shot, commit both or supersede.
|
||||
d. Docs (README "real towns" paragraph, LANE_F_NOTES §18/§19 honest record incl. why F
|
||||
correctly didn't run in R18), tag **`v4.0-alpha`**, push (standing authorization).
|
||||
3. **D — re-measure after A's connectivity pass.** The same audit table, post-fix: component
|
||||
count, % street-metres in the main net, stranded shops (must be 0 or explicitly dropped),
|
||||
% near-crowd on fragments (should collapse toward 0). One table, one verdict line. Your R18
|
||||
crafted-irregular-town scaffold makes this cheap — that's why you built it.
|
||||
4. **B — the town selector (beta pull-forward, NON-blocking for the tag).** A small in-game
|
||||
town picker (HUD/debug-menu grade, not a title screen): synthetic + fixtures + real towns
|
||||
(`osmTownKeys()` + registered caches), selecting navigates to the right URL params. Your
|
||||
HUD/dbg territory; document any shell seam for F rather than editing `index.html` (F owns it).
|
||||
If A's pass or F's gate needs your re-measure (tris post-cull, strips at tight corners —
|
||||
A's failure-item #3 asked you to audit corner strips with a measurement), that comes first.
|
||||
5. **C — on-call.** Your alpha work is done (20/20). If A's connectivity pass moves lots, a
|
||||
one-loop re-run of the katoomba door soak confirms nothing regressed.
|
||||
6. **E — on-call.** Re-emit caches only if A's junction handling needs different data (e.g.
|
||||
pre-DP full geometry is already shipped — likely nothing). Your data discipline this epoch
|
||||
has been faultless; nothing else asked.
|
||||
|
||||
## Laws
|
||||
|
||||
Charter laws (V4_REAL_MAP.md) unchanged. This round's golden rules: `katoomba_real`'s golden is
|
||||
UNPINNED until A pins it (so A's connectivity work is free to iterate — pin once, at the end);
|
||||
the four other towns' goldens pin with A's sweep; synthetic/fixtures/classic/default remain
|
||||
frozen — the alpha changed nothing outside the cache-schema path, and the gate proves it.
|
||||
|
||||
## Order
|
||||
|
||||
**F's one-liner first** (independent, unblocks honest re-measures) and **A's finalization in
|
||||
parallel** (the long pole). Then **D re-measures**, **C spot-confirms**, **B's selector** rides
|
||||
alongside. **F gates last** once A pins and D confirms. Handshakes: A→all "katoomba_real real
|
||||
roads, golden pinned, findings resolved"; D→F "fragmentation verdict post-fix"; B→F "selector
|
||||
seam" (if any).
|
||||
|
||||
## Standing
|
||||
|
||||
- **John — nothing blocks this round.** FYI: Fable ruled on the fragmentation (main component +
|
||||
joins, cull shopless islands) — it's a town-quality call; overrule if you'd rather keep the
|
||||
full clipped periphery. The tag lands as `v4.0-alpha` at close.
|
||||
- **v4.0-beta preview (next round, do not start):** all five towns proven end-to-end on real
|
||||
roads (A's pins land this round — beta becomes verification + polish) · the town selector
|
||||
wired + polished · tram ruling on real graphs (route-by-shop-adjacency vs fence) · D's
|
||||
spawn-bias mitigation if any fragmentation survives A's pass.
|
||||
- **Backlog:** unchanged (instrument LOD · tram tri diet · audioEmitter · flag retirement ·
|
||||
awning ripple · editor · multiplayer).
|
||||
@ -17,7 +17,7 @@ attribution ships IN every cache (`license`/`attribution`) + `SOURCES.md`. Hours
|
||||
registry supplies them at plan time (contract shop = {id,name,type,lat,lon,suburb}). Overpass is an
|
||||
outward-facing fetch (John-authorized scout, once per town, raw cached).
|
||||
"""
|
||||
import os, sys, json, re, math, time, datetime, urllib.request, urllib.parse
|
||||
import os, sys, json, re, math, time, datetime, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOWNS_DIR = os.path.join(ROOT, "web", "assets", "towns")
|
||||
@ -43,6 +43,18 @@ TYPE_MAP = {
|
||||
"charity": "opshop", "second_hand": "opshop", "antiques": "opshop",
|
||||
}
|
||||
|
||||
# ── roads[] (schema v2, ROUND18 REAL ROADS) ─────────────────────────────────────────────────────
|
||||
# We FETCH every OSM highway tier A's ROAD_KIND maps (→ raw cache, full provenance / beta-ready) but
|
||||
# EMIT only the town STREET graph. Fidelity cut (charter risk #4, flagged to A who owns the knob):
|
||||
# the service/track/footway/path/steps/cycleway tier is EXCLUDED — in Katoomba that's 1461 of 2506
|
||||
# ways (Blue-Mountains bushwalking tracks, sidewalks, stairs, driveways, parking aisles), noise for a
|
||||
# town street graph and a way-count A's lift would build 2500+ edges from. The full set stays in the
|
||||
# raw response if A wants a tier back. `kind` ships as the raw OSM highway class; A maps it (ROAD_KIND).
|
||||
ROAD_HW = ("motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|road"
|
||||
"|service|track|pedestrian|footway|path|steps|cycleway")
|
||||
STREET_KINDS = {"motorway", "trunk", "primary", "secondary", "tertiary", "unclassified",
|
||||
"residential", "living_street", "road", "pedestrian"}
|
||||
|
||||
# The big trademarked charity/franchise chains get loving parodies (ported from thriftgod, John's call
|
||||
# for the live game); independent shops keep their real OSM names (factual ODbL data).
|
||||
PARODY = [
|
||||
@ -86,6 +98,22 @@ def haversine_m(a, b):
|
||||
return 2 * R * math.asin(min(1.0, math.sqrt(h)))
|
||||
|
||||
|
||||
def overpass_post(q):
|
||||
"""POST an Overpass query with retry/backoff — the public API 504s / times out under load."""
|
||||
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
|
||||
headers={"User-Agent": UA})
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
return json.load(r).get("elements", [])
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt == 3:
|
||||
raise
|
||||
wait = 15 * (attempt + 1)
|
||||
print(f" Overpass {getattr(e, 'code', type(e).__name__)} — retry {attempt + 1}/3 in {wait}s")
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
def fetch_raw(key, bb, refetch):
|
||||
"""Return the raw Overpass elements list, cached in-repo so re-runs never re-fetch."""
|
||||
os.makedirs(RAW_DIR, exist_ok=True)
|
||||
@ -98,10 +126,7 @@ def fetch_raw(key, bb, refetch):
|
||||
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
|
||||
f'out center;')
|
||||
print(f" [{key}] fetching Overpass (bounded {bb['minLat']:.3f},{bb['minLon']:.3f} … {bb['maxLat']:.3f},{bb['maxLon']:.3f})")
|
||||
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
|
||||
headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=150) as r:
|
||||
els = json.load(r).get("elements", [])
|
||||
els = overpass_post(q)
|
||||
fetched = datetime.date.today().isoformat()
|
||||
json.dump({"fetchedAt": fetched, "endpoint": OVERPASS, "query": q, "bbox": bb, "elements": els},
|
||||
open(path, "w"), indent=1)
|
||||
@ -109,7 +134,46 @@ def fetch_raw(key, bb, refetch):
|
||||
return els, fetched
|
||||
|
||||
|
||||
def process(key, cfg, els, fetched):
|
||||
def fetch_roads_raw(key, bb, refetch):
|
||||
"""Return the raw OSM highway ways (with geometry) for the town bbox, cached in-repo like the shops."""
|
||||
os.makedirs(RAW_DIR, exist_ok=True)
|
||||
path = os.path.join(RAW_DIR, key + ".roads.overpass.json")
|
||||
if os.path.exists(path) and not refetch:
|
||||
return json.load(open(path))["elements"]
|
||||
q = (f'[out:json][timeout:120];'
|
||||
f'way["highway"~"^({ROAD_HW})$"]'
|
||||
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
|
||||
f'out geom;')
|
||||
print(f" [{key}] fetching Overpass roads (highway ways, bounded)")
|
||||
ways = overpass_post(q)
|
||||
json.dump({"fetchedAt": datetime.date.today().isoformat(), "endpoint": OVERPASS, "query": q,
|
||||
"bbox": bb, "elements": ways}, open(path, "w"), indent=1)
|
||||
time.sleep(2)
|
||||
return ways
|
||||
|
||||
|
||||
def roads_from_ways(ways):
|
||||
"""OSM highway ways → roads[] (contract v2): {id, kind, name?, pts:[[lat,lon],…]}. Emit only the
|
||||
STREET tier; ship full geometry (A simplifies with Douglas–Peucker); deterministic (id-sorted)."""
|
||||
roads = []
|
||||
for w in ways:
|
||||
hwy = w.get("tags", {}).get("highway")
|
||||
geom = w.get("geometry")
|
||||
if hwy not in STREET_KINDS or not isinstance(geom, list) or len(geom) < 2:
|
||||
continue
|
||||
pts = [[round(g["lat"], 6), round(g["lon"], 6)] for g in geom
|
||||
if isinstance(g.get("lat"), (int, float)) and isinstance(g.get("lon"), (int, float))]
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
rd = {"id": w["id"], "kind": hwy, "pts": pts}
|
||||
name = w.get("tags", {}).get("name")
|
||||
if name:
|
||||
rd["name"] = name[:60]
|
||||
roads.append(rd)
|
||||
return sorted(roads, key=lambda r: r["id"])
|
||||
|
||||
|
||||
def process(key, cfg, els, fetched, roads=None):
|
||||
center = cfg["center"]
|
||||
rows, seen = [], set()
|
||||
for el in els:
|
||||
@ -144,14 +208,19 @@ def process(key, cfg, els, fetched):
|
||||
shops.append({"id": r["id"], "name": pname, "type": TYPE_MAP.get(r["osm"], "opshop"),
|
||||
"lat": round(r["lat"], 6), "lon": round(r["lon"], 6), "suburb": r["suburb"]})
|
||||
bb = bbox(center, cfg["span_km"])
|
||||
return {
|
||||
"schema": "procity-town-cache/1", "key": key, "town": cfg["town"], "source": "osm",
|
||||
counts = {"raw": len(els), "shops": len(shops)}
|
||||
cache = {
|
||||
"schema": "procity-town-cache/2" if roads else "procity-town-cache/1",
|
||||
"key": key, "town": cfg["town"], "source": "osm",
|
||||
"license": "ODbL 1.0", "attribution": "© OpenStreetMap contributors",
|
||||
"generator": "pipeline/build_towns.py", "fetchedAt": fetched,
|
||||
"center": {"lat": center[0], "lon": center[1]},
|
||||
"bbox": bb, "counts": {"raw": len(els), "shops": len(shops)},
|
||||
"shops": shops,
|
||||
"bbox": bb, "counts": counts, "shops": shops,
|
||||
}
|
||||
if roads:
|
||||
counts["roads"] = len(roads)
|
||||
cache["roads"] = roads # schema v2 — A's plan_osm builds the real street graph
|
||||
return cache
|
||||
|
||||
|
||||
def span_km(shops):
|
||||
@ -166,24 +235,35 @@ def span_km(shops):
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
refetch = "--refetch" in sys.argv
|
||||
no_roads = "--no-roads" in sys.argv # build a v1 cache (marched fallback), no road fetch
|
||||
keys = args or list(TOWNS)
|
||||
os.makedirs(TOWNS_DIR, exist_ok=True)
|
||||
kept, dropped = [], []
|
||||
for key in keys:
|
||||
cfg = TOWNS[key]
|
||||
els, fetched = fetch_raw(key, bbox(cfg["center"], cfg["span_km"]), refetch)
|
||||
cache = process(key, cfg, els, fetched)
|
||||
bb = bbox(cfg["center"], cfg["span_km"])
|
||||
els, fetched = fetch_raw(key, bb, refetch)
|
||||
roads = None
|
||||
if not no_roads:
|
||||
roads = roads_from_ways(fetch_roads_raw(key, bb, refetch))
|
||||
cache = process(key, cfg, els, fetched, roads)
|
||||
n, sp = len(cache["shops"]), span_km(cache["shops"])
|
||||
types = {}
|
||||
for s in cache["shops"]:
|
||||
types[s["type"]] = types.get(s["type"], 0) + 1
|
||||
status = "OK " if n >= 6 and sp < 5.0 else "LOW"
|
||||
if n >= 6 and sp < 5.0:
|
||||
ok = n >= 6 and sp < 5.0
|
||||
if ok:
|
||||
json.dump(cache, open(os.path.join(TOWNS_DIR, key + ".json"), "w"), indent=1)
|
||||
kept.append(key)
|
||||
else:
|
||||
dropped.append((key, n, sp))
|
||||
print(f" {status} {key:16} raw={cache['counts']['raw']:>4} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}")
|
||||
rstr = ""
|
||||
if roads is not None:
|
||||
rk = {}
|
||||
for r in roads:
|
||||
rk[r["kind"]] = rk.get(r["kind"], 0) + 1
|
||||
rstr = f" roads={len(roads)} {dict(sorted(rk.items(), key=lambda x: -x[1]))}"
|
||||
print(f" {'OK ' if ok else 'LOW'} {key:16} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}{rstr}")
|
||||
print(f"\nBUILT {len(kept)} town cache(s) → web/assets/towns/: {', '.join(kept)}")
|
||||
if dropped:
|
||||
print(f"DROPPED (< 6 shops or span ≥ 5 km): {dropped}")
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -27,10 +27,20 @@ the "© OpenStreetMap contributors (ODbL)" credit visible.
|
||||
|
||||
## Raw responses (`_raw/`)
|
||||
|
||||
The raw Overpass JSON for each town is cached in `_raw/<key>.overpass.json` (wrapped with `fetchedAt`,
|
||||
`endpoint`, `query`, `bbox`). **Re-runs reuse the raw cache and never re-hit Overpass** — the fetch is
|
||||
outward-facing network (John-authorized scout, once per town). `--refetch` forces a deliberate re-fetch.
|
||||
`selfcheck.js` reads only top-level `*.json`, so `_raw/` is never loaded as a cache.
|
||||
The raw Overpass JSON is cached in-repo (wrapped with `fetchedAt`, `endpoint`, `query`, `bbox`):
|
||||
`_raw/<key>.overpass.json` (shops) and `_raw/<key>.roads.overpass.json` (OSM highway ways, schema v2).
|
||||
**Re-runs reuse the raw cache and never re-hit Overpass** — the fetch is outward-facing network
|
||||
(John-authorized scout, once per town). `--refetch` forces a deliberate re-fetch. `selfcheck.js` reads
|
||||
only top-level `*.json`, so `_raw/` is never loaded as a cache.
|
||||
|
||||
## Roads — schema v2 (ROUND18, v4.0-alpha REAL ROADS)
|
||||
|
||||
A v2 cache carries `roads[]` — the town's real OSM street geometry (`highway=*` ways), same ODbL licence
|
||||
and attribution as the shops. **Fidelity cut** (charter risk #4): the raw fetch grabs every `ROAD_KIND`
|
||||
tier, but the emitted `roads[]` is the **town street graph only** — drivable roads + pedestrian malls;
|
||||
`service`/`track`/`footway`/`path`/`steps`/`cycleway` are excluded (in Katoomba, 1461 of 2506 ways —
|
||||
bushwalking tracks, sidewalks, stairs, driveways). The excluded tier stays in the raw response if a
|
||||
tier is wanted back. `katoomba_real` is v2 (the alpha); the other towns' roads land as data-only.
|
||||
|
||||
## Names — parody vs factual
|
||||
|
||||
|
||||
310184
web/assets/towns/_raw/bendigo_real.roads.overpass.json
Normal file
310184
web/assets/towns/_raw/bendigo_real.roads.overpass.json
Normal file
File diff suppressed because it is too large
Load Diff
48837
web/assets/towns/_raw/castlemaine_real.roads.overpass.json
Normal file
48837
web/assets/towns/_raw/castlemaine_real.roads.overpass.json
Normal file
File diff suppressed because it is too large
Load Diff
126643
web/assets/towns/_raw/fremantle_real.roads.overpass.json
Normal file
126643
web/assets/towns/_raw/fremantle_real.roads.overpass.json
Normal file
File diff suppressed because it is too large
Load Diff
139052
web/assets/towns/_raw/katoomba_real.roads.overpass.json
Normal file
139052
web/assets/towns/_raw/katoomba_real.roads.overpass.json
Normal file
File diff suppressed because it is too large
Load Diff
173169
web/assets/towns/_raw/newtown_real.roads.overpass.json
Normal file
173169
web/assets/towns/_raw/newtown_real.roads.overpass.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
||||
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';
|
||||
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
|
||||
|
||||
const r2 = v => Math.round(v * 100) / 100;
|
||||
const r4 = v => Math.round(v * 10000) / 10000;
|
||||
@ -48,9 +49,40 @@ 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';
|
||||
|
||||
// Douglas–Peucker polyline simplification in projected metres (the road-fidelity knob, charter risk #4).
|
||||
// Iterative (no recursion-depth risk on a long way). Keeps endpoints + any point > eps off the chord.
|
||||
function simplifyDP(pts, eps) {
|
||||
if (pts.length < 3) return pts.slice();
|
||||
const keep = new Uint8Array(pts.length); keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [i0, i1] = stack.pop();
|
||||
const a = pts[i0], b = pts[i1], dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz) || 1;
|
||||
let far = -1, fd = eps;
|
||||
for (let i = i0 + 1; i < i1; i++) {
|
||||
const d = Math.abs((pts[i].x - a.x) * dz - (pts[i].z - a.z) * dx) / L; // perpendicular distance to the chord
|
||||
if (d > fd) { fd = d; far = i; }
|
||||
}
|
||||
if (far >= 0) { keep[far] = 1; stack.push([i0, far], [far, i1]); }
|
||||
}
|
||||
return pts.filter((_, i) => keep[i]);
|
||||
}
|
||||
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 +90,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 +119,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 };
|
||||
}
|
||||
@ -145,51 +195,115 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── project real shops → local metres; bin by latitude into avenue rows, order by longitude ──
|
||||
// ── projection to local metres (equirectangular about the cache centre) ──
|
||||
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 projX = lon => (lon - fx.center.lon) * EARTH_M * cosLat;
|
||||
const projZ = lat => (lat - fx.center.lat) * EARTH_M;
|
||||
const footOf = t => { const fp = SHOP_TYPES[t]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; };
|
||||
const district = addDistrict('mainstreet', 0, 0);
|
||||
const roads = Array.isArray(fx.roads) ? fx.roads : [];
|
||||
|
||||
// v2 cache with roads[] ⇒ the real street graph; else the v1 marched fallback (SAME CityPlan out).
|
||||
if (roads.length) { norm.mode = 'roads'; buildRealRoads(); } else { norm.mode = 'marched'; buildMarched(); }
|
||||
|
||||
// ── v1 marched fallback: bin shops by latitude into synthetic avenues off one S→N spine ──
|
||||
function buildMarched() {
|
||||
const pts = fx.shops.map(s => ({ s, x: projX(s.lon), z: projZ(s.lat) }));
|
||||
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) rowsArr[Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)))].push(p);
|
||||
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)); });
|
||||
for (let k = 0; k < spineNodes.length - 1; k++) addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main');
|
||||
usedRows.forEach((o) => {
|
||||
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, fr = rowShops.map(p => footOf(p.s.type));
|
||||
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
|
||||
const avEdge = addEdge(addNode(0, zPos).id, addNode(avLen, zPos).id, AV_W, 'side');
|
||||
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); // facade faces +z (north) to the avenue
|
||||
let t = SPINE_CLEAR;
|
||||
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
// ── v4 REAL ROADS (schema v2): the CityPlan street graph FROM real OSM ways; shops on real streets ──
|
||||
// project → simplify (Douglas–Peucker) → snap coincident points to shared nodes (= real intersections)
|
||||
// → straight-segment edges → classify main by shop density → seat each shop by marching along its
|
||||
// NEAREST real edge in real order/real side (regularised spacing + an overlap-resolve pass ⇒ the
|
||||
// stringent structuralSuite still holds). Fidelity is the alpha knob (charter risk #4): real edge,
|
||||
// real order, real side, regularised spacing — not pixel positions (real shops overlap).
|
||||
function buildRealRoads() {
|
||||
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 6; // fidelity + geometry knobs (metres)
|
||||
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
|
||||
const ways = [];
|
||||
for (const rd of roads) {
|
||||
if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) continue;
|
||||
let pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])).map(([lat, lon]) => ({ x: projX(lon), z: projZ(lat) }));
|
||||
pl = simplifyDP(pl, EPS);
|
||||
if (pl.length >= 2) ways.push({ kind: roadEdgeKind(rd.kind), pl });
|
||||
}
|
||||
const nodeAt = new Map();
|
||||
const snap = p => { const k = `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`; let n = nodeAt.get(k); if (!n) { n = addNode(p.x, p.z); nodeAt.set(k, n); } return n; };
|
||||
const seen = new Set(), realEdges = [];
|
||||
for (const w of ways) for (let i = 0; i + 1 < w.pl.length; i++) {
|
||||
const na = snap(w.pl[i]), nb = snap(w.pl[i + 1]);
|
||||
if (na.id === nb.id || Math.hypot(nb.x - na.x, nb.z - na.z) < 3) continue;
|
||||
const ek = `${Math.min(na.id, nb.id)}-${Math.max(na.id, nb.id)}`; if (seen.has(ek)) continue; seen.add(ek);
|
||||
realEdges.push({ id: addEdge(na.id, nb.id, WKIND[w.kind] || AV_W, w.kind), a: na, b: nb, kind: w.kind });
|
||||
}
|
||||
if (!realEdges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; }
|
||||
const nearest = (px, pz) => {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of realEdges) {
|
||||
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, L2 = dx * dx + dz * dz || 1;
|
||||
let t = ((px - e.a.x) * dx + (pz - e.a.z) * dz) / L2; t = Math.max(0, Math.min(1, t));
|
||||
const d = Math.hypot(px - (e.a.x + dx * t), pz - (e.a.z + dz * t));
|
||||
if (d < bd) { bd = d; best = { e, t, side: (dx * (pz - e.a.z) - dz * (px - e.a.x)) >= 0 ? 1 : -1 }; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const perEdge = new Map();
|
||||
for (const s of fx.shops) {
|
||||
const a = nearest(projX(s.lon), projZ(s.lat)); if (!a) continue;
|
||||
const key = a.e.id * 2 + (a.side > 0 ? 1 : 0);
|
||||
(perEdge.get(key) || perEdge.set(key, { e: a.e, side: a.side, list: [] }).get(key)).list.push({ s, t: a.t });
|
||||
}
|
||||
if (!realEdges.some(e => e.kind === 'main')) { // classify main from shop density if none from OSM
|
||||
const cnt = new Map(); for (const { e, list } of perEdge.values()) cnt.set(e.id, (cnt.get(e.id) || 0) + list.length);
|
||||
let top = null, tc = -1; for (const e of realEdges) { const c = cnt.get(e.id) || 0; if (c > tc) { tc = c; top = e; } }
|
||||
if (top) { const pe = edges.find(x => x.id === top.id); pe.kind = 'main'; pe.width = SPINE_W; }
|
||||
}
|
||||
const cands = [];
|
||||
for (const { e, side, list } of perEdge.values()) {
|
||||
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
|
||||
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
|
||||
const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (−Z) faces road
|
||||
const off = KERB, dep = DEPTH;
|
||||
const block = addBlock(district, 'mainstreet', [
|
||||
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
|
||||
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);
|
||||
let along = NODE_CLEAR;
|
||||
for (const { s } of list) {
|
||||
const f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop');
|
||||
if (along + f > len - NODE_CLEAR) break; // edge full — overflow shops drop out (real order kept)
|
||||
cands.push({ block, x: e.a.x + ux * (along + f / 2) + nx * (off + dep / 2), z: e.a.z + uz * (along + f / 2) + nz * (off + dep / 2), w: f, d: dep, ry, frontEdge: e.id, s });
|
||||
along += f + GAP;
|
||||
}
|
||||
}
|
||||
cands.sort((p, q) => p.s.id - q.s.id); // real streets crowd at intersections → deterministic overlap-resolve
|
||||
const placed = [];
|
||||
for (const c of cands) {
|
||||
const corners = lotCorners(c);
|
||||
if (placed.some(p => obbOverlap(p.corners, corners))) { norm.dropped = (norm.dropped || 0) + 1; continue; }
|
||||
placed.push({ corners, c });
|
||||
}
|
||||
for (const { c } of placed) addShop(addLot(c.block, c.x, c.z, c.w, c.d, c.ry, c.frontEdge, 'shop'), c.s);
|
||||
}
|
||||
|
||||
// ── exactly one late-night landmark: a video rental, closes ≥ LATE_HOUR (matches synthetic) ──
|
||||
if (shops.length) {
|
||||
|
||||
@ -406,13 +406,55 @@ for (const [label, cache, needle] of [
|
||||
ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`);
|
||||
ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`);
|
||||
}
|
||||
// (e) v4 REAL ROADS (schema v2, ROUND18): a v2 cache with roads[] builds the CityPlan street graph FROM
|
||||
// the real ways and STILL passes the full structural + gig suites. Synthetic irregular graph — a main
|
||||
// street, two cross streets (a real intersection + an acute-ish angle), a dead-end lane, a slight curve —
|
||||
// the committed proof of the graph lift; `katoomba_real` validates + pins its golden when E's roads land.
|
||||
section('v4 real-roads graph lift (schema v2)');
|
||||
{
|
||||
const roadsCache = {
|
||||
schema: 'procity-town-cache/2', key: 'roadstest', town: 'Roadstest', source: 'osm',
|
||||
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.71, lon: 150.31 },
|
||||
roads: [
|
||||
{ kind: 'primary', name: 'Main St', pts: [[-33.7150, 150.3110], [-33.7130, 150.31108], [-33.7110, 150.31116], [-33.7090, 150.31124], [-33.7070, 150.31132]] },
|
||||
{ kind: 'residential', name: 'Cross A', pts: [[-33.7130, 150.31108], [-33.7130, 150.3090]] },
|
||||
{ kind: 'residential', name: 'Cross B', pts: [[-33.7100, 150.3112], [-33.7095, 150.3130]] },
|
||||
{ kind: 'service', name: 'Back Lane', pts: [[-33.7120, 150.31112], [-33.7122, 150.3122]] },
|
||||
],
|
||||
shops: Array.from({ length: 18 }, (_, i) => ({
|
||||
id: 700 + i, name: `Real St Shop ${i}`,
|
||||
type: ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
|
||||
lat: -33.7150 + i * 0.00045, lon: 150.3110 + ((i % 3) - 1) * 0.0005, suburb: 'Katoomba',
|
||||
})),
|
||||
};
|
||||
const v = validateTownCache(roadsCache);
|
||||
ok(v.ok, `roads cache: validates as schema v2` + (v.ok ? '' : ` — ${v.errors.join('; ')}`));
|
||||
for (const s of [1, 42, 20261990]) {
|
||||
const p = generatePlanOSM(s, 'roadstest', { cache: roadsCache });
|
||||
ok(p.streets.edges.length >= 3, `roads ${s}: multi-edge real graph (${p.streets.edges.length} edges from real ways)`);
|
||||
ok(p.streets.edges.some(e => e.kind === 'main'), `roads ${s}: a main spine exists (OSM class or shop-density promoted)`);
|
||||
ok(p.shops.length >= 6, `roads ${s}: ${p.shops.length} shops seated on real edges (overlap-resolved)`);
|
||||
structuralSuite(p, `roads syn ${s}`);
|
||||
}
|
||||
ok(JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })) === JSON.stringify(generatePlanOSM(20261990, 'roadstest', { cache: roadsCache })),
|
||||
`roads: deterministic (byte-identical re-run)`);
|
||||
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'roadstest', cache: roadsCache }), `roads syn`);
|
||||
// roads-absent (v1) marches — proven identical to before by the frozen osm goldens above
|
||||
ok(generatePlanOSM(1, 'x', { cache: { ...roadsCache, roads: undefined } }).streets.edges.some(e => e.kind === 'main'),
|
||||
`roads absent: marched fallback still boots`);
|
||||
}
|
||||
|
||||
// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ──
|
||||
// Empty until E's caches land; each is validated, run through the full structural + gig suites, and
|
||||
// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive.
|
||||
section('real town caches (web/assets/towns/*.json)');
|
||||
const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
|
||||
const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: <key>: 0x… */ };
|
||||
// The trailing towns are pinned on their stable v1 (marched) output — regression guards. katoomba_real
|
||||
// stays UNPINNED this round: it is the v4.0-alpha town, and its golden re-pins to the real-ROADS output
|
||||
// the moment E lands its roads[] (amendment law — the A→E→A finalization). The other four get roads in beta.
|
||||
const REAL_TOWN_GOLDENS = {
|
||||
bendigo_real: 0x5e3e76a6, castlemaine_real: 0xf1a628a1, fremantle_real: 0xee9fd09a, newtown_real: 0x95978b45,
|
||||
};
|
||||
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json'));
|
||||
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
|
||||
for (const f of townFiles) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user