diff --git a/D-progress.md b/D-progress.md index 8eb1e36..2c45d00 100644 --- a/D-progress.md +++ b/D-progress.md @@ -1,5 +1,69 @@ # PROCITY-D — progress (Lane D · Citizens) +## Round 40 (§40.5) — the door points and the footpath: measured, kerb-clamped, gated. (2026-08-04) + +**Verification first, per the brief.** New gate `tools/qa/door_footpath_check.mjs` re-derives every +door point EXACTLY as the shell does (`lot + front-normal·(d/2+0.6)`, web/index.html:433-437) and +classifies it against the plan's own geometry (registry's `vergeBand` corridor law + lots + blocks). +All numbers below are from today's runs on today's tree. + +| town | R39 (shipped) | today, B's fix only — footpath | today — walkable front | after D's clamp — footpath | **after D's clamp — walkable front** | +|---|---|---|---|---|---| +| synthetic@20261990 | 0/493 | 447/493 · 90.7% | 473 · 95.9% | 447 · 90.7% | **473/493 · 95.9%** ✅ | +| katoomba_real | 0/72 | 71/72 · 98.6% | 71 · 98.6% | 72 · 100% | **72/72 · 100%** ✅ | +| fitzroy_real | 0/139 | 138/139 · 99.3% | 138 · 99.3% | 139 · 100% | **139/139 · 100%** ✅ | +| bowral_real | 0/30 | 28/30 · 93.3% | 28 · 93.3% | 30 · 100% | **30/30 · 100%** ✅ | + +**Verdict: B's two-character R39 fix did the heavy lifting (0% → ~91–99%) but did NOT clear the +≥95% gate alone** — bowral read 93.3% and the synthetic 90.7% strict. Two residue species, and they +wanted different answers: + +1. **Kerb misses (fixed by D).** On real towns `plan_osm` sets lots back at + `max(KERB, roadWidth/2 + FOOTPATH)`, so on narrow corridors the facade sits past the corridor + edge and the shell's `+0.6` nudge lands the door point **0.85 m outside the verge band** — + 1/72 katoomba, 1/139 fitzroy, 2/30 bowral. Fix: **`web/js/citizens/door_snap.js`** (new, pure, + no THREE, no rng) — clamp a door point into the footpath band of its nearest footpath-bearing + street, applied at intake in `sim.setShops`. Registry's published `vergeBand` over the sim's own + edges; graph-only stays graph-only. Capped at 6 m so a plaza front is never dragged to a street + it doesn't front; margin 0.45 m off the band edges. Transition-checked corpus-wide: **fixes 4, + breaks 0** (a gate arm asserts breaks = 0 forever). Live-shell verified: booted the game, + diffed `sim.shopsByChunk` against the raw derivation in the running page — 493 points, 2 moved, + max 5.41 m, exactly matching the node run. +2. **Plaza fronts (a definition, not a defect).** The synthetic's 36-shop strict-footpath residue + is **market stalls fronting the market square** (`use:"stall"`, block 25 `kind:"market"`) and + arcade shops — their front IS the square, they can never be in a street's verge band, and + dragging their door 50–73 m to a street they don't front (measured: that's what an uncapped + snap does) would re-break the front-door truth. The gate therefore scores **walkable front + ground** — footpath, or the open ground of a `market`/`arcade` block (outside every lot, off + every carriageway) — and reports the strict R39-style footpath number alongside for continuity. + Peds never reach stalls anyway (50–73 m from every walkable edge vs PATRON_RANGE 18) — their + door points are inert data today. + +**Remaining synthetic residue (20/493, under the floor, documented not fixed):** stall-vs-stall +front overlaps (6), stalls at the square's edge clipping a carriageway (8), 3 shop + 1 anchor +corner cases, 2 shops fronting into a neighbour lot. All are **derivation** artefacts of the fixed +`d/2+0.6` nudge in `web/index.html:433-437` (F's shell, B's R39 hands) — if a later round wants +them, the recommended fix is deriving from `buildShopfront`'s own `doorRect` instead of the fixed +nudge; measured detail in LANE_D_NOTES §40. Out of D's graph-only reach and under the gate's floor. + +**Gate arms (all green, 12/12):** rate ≥95% on all four towns · CONTROL — the back point (the exact +R8–R39 defect) must score under the floor on every town or the classifier is vacuous (it reads +0.4–13.3%) · FIX-ONLY — the clamp breaks 0 previously-passing points. Deterministic: two runs +byte-identical JSON. **Goldens untouched: selfcheck 157,647/157,647, fingerprint `0x5f76e76`** (the +clamp is runtime pose in D's sim; citygen never touched). + +**The picture:** `docs/shots/laneD/r40_front_duckin.jpg` — a rigged ped mid-stride ON the footpath, +frozen 3.2 m from the VIDEO BARN front door it is ducking into (staged via the sim's own +`_beginVisit` + a pause at the threshold; steering, door point and entry are the shipped code). + +**→ Fable/Lane F (one line, as briefed):** please wire `node tools/qa/door_footpath_check.mjs` into +`tools/qa.sh` (rc 0/1, ~2 s, zero deps — same shape as the other node gates). Not editing qa.sh +myself; F owns the runner. + +Files touched: `web/js/citizens/door_snap.js` (new) · `web/js/citizens/sim.js` (import + setShops +clamp) · `tools/qa/door_footpath_check.mjs` (new gate) · `docs/shots/laneD/r40_front_duckin.jpg` +(new) · this file · LANE_D_NOTES §40. + ## Round 12 (v3.0-alpha) — the band and the crowd, behind `?gigs=1`. (2026-07-15) Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points. diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 297a6d6..06a4f84 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,74 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate + +Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want. + +### Today's numbers (all re-measured 2026-08-04, `tools/qa/door_footpath_check.mjs`) + +R39 shipped **0 door points on a footpath corpus-wide** (0/493 · 0/72 · 0/139 · 0/30 — the +`(−sin,−cos)` sign at `index.html:428`-era, B's lineage table). B's R39 `+sin/+cos` fix, measured +on today's tree, before any D change: + +| town | strict footpath | walkable front | residue classes (walkable) | +|---|---|---|---| +| synthetic@20261990 | 447/493 · 90.7% | 473 · 95.9% | shop:carriageway 3 · stall:carriageway 8 · stall:in-a-lot 6 · anchor:off-verge 1 · shop:in-a-lot 2 | +| katoomba_real | 71/72 · 98.6% | 71 · 98.6% | shop:off-verge 1 (0.85 m) | +| fitzroy_real | 138/139 · 99.3% | 138 · 99.3% | shop:off-verge 1 (0.85 m) | +| bowral_real | 28/30 · 93.3% | 28 · 93.3% | shop:off-verge 2 (0.85 m) | + +After D's clamp (`door_snap.js` in `sim.setShops`): **katoomba 72/72, fitzroy 139/139, bowral +30/30 — 100% on both measures; synthetic unchanged at 473/493 walkable (95.9%)**. Corpus +transitions: fixes 4, breaks 0, moves 7 points total, max move 5.41 m (synthetic) / 0.85 m (real). + +### The two residue species, and why they got different answers + +1. **The 0.85 m kerb miss (fixed here).** `plan_osm.js:466-468` sets a real-town lot back at + `max(KERB, roadWidth(pe)/2 + FOOTPATH)` from the centreline. On corridors where that exceeds + `edge.width/2`, the facade stands past the corridor edge, so the shell's door point + (`d/2 + 0.6`) lands just OUTSIDE `vergeBand(e)` — off the walkable strip by under a metre. The + ped steered to the gutter shadow, not the footpath. `snapDoorToFootpath` clamps the point into + `[kerb+0.45, corridorEdge−0.45]` of its nearest footpath-bearing edge. Registry's published + split, the sim's own edges — no lots, no plan, no rng, so "sim stays graph-only" holds and same + seed → same doors. +2. **The plaza fronts (measured, deliberately NOT dragged).** The synthetic's big strict-footpath + residue is 36 market stalls + arcade shops, 50–73 m from every footpath-bearing edge, fronting + the market square / covered arcade. An UNCAPPED snap "passes" them (measured: 488/493 strict) + by moving doors up to **59.85 m** onto a main street they do not front — peds would duck into a + stall by vanishing at a point three shops away. That is the R8 defect wearing a green tick. The + 6 m cap exists to forbid exactly this; the gate scores plaza ground as a valid FRONT instead. + +### For whoever owns the derivation next (F's shell / B was last in) + +The remaining 20 synthetic misses are artefacts of the **fixed nudge** at `web/index.html:433-437`: +`door = lot + (sin ry, cos ry) · (d/2 + 0.6)` assumes 0.6 m in front of the facade midline is clear +public ground. On 4.5 m stalls packed in the square, that lands inside the neighbouring stall +(6 cases) or past the square's kerb line (8); on two synthetic corner shops it clips the cross +street's carriageway. If a round ever needs those: derive the point from `buildShopfront`'s own +`doorRect` (buildings.js:410-416, :583-597 — the actual door, not the lot midline) instead of the +nudge. I did not touch it — not my file, and the gate passes without it. + +### The gate (`tools/qa/door_footpath_check.mjs`) — what keeps this honest + +- Imports the SHIPPED `door_snap.js` (the sim's code path, not a copy) and replicates the shell's + raw derivation; measures both, so a regression in either shows as its own number. +- **CONTROL:** the back point — the exact R8→R39 defect — must score under the floor on every town + (reads 0.4–13.3%). A heading has no falsifiable arm inside the file that computes it (B's R39 + keeper insight), so this gate carries one. +- **FIX-ONLY:** the clamp may break 0 previously-passing points, ever. +- Deterministic (two runs byte-identical), ~2 s, zero deps, exit 0/1. **→ F: one line in qa.sh, + please — `node tools/qa/door_footpath_check.mjs` with the other node gates.** +- Goldens: selfcheck 157,647/157,647, fingerprint `0x5f76e76`, run today after the change. + +### Shot + +`docs/shots/laneD/r40_front_duckin.jpg` — near-tier rig frozen 3.2 m from the VIDEO BARN door, +mid-stride ON the footpath under the verandah (staged via the sim's own `_beginVisit` + `setPaused` +at the threshold; the steering, the stored door point and the entry radius are all shipped code). + +--- + ## ROUND 36 (wave 2.5) — CLASSIC KEEPS ITS PEOPLE: the DJ bodies gated out of `?classic` **The ruling (Fable, on F's §36.4 held warn):** `b339402` (July 20) appended the 5 trellis'd DJ diff --git a/docs/shots/laneD/r40_front_duckin.jpg b/docs/shots/laneD/r40_front_duckin.jpg new file mode 100644 index 0000000..c222513 Binary files /dev/null and b/docs/shots/laneD/r40_front_duckin.jpg differ diff --git a/tools/qa/door_footpath_check.mjs b/tools/qa/door_footpath_check.mjs new file mode 100644 index 0000000..13aa889 --- /dev/null +++ b/tools/qa/door_footpath_check.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// PROCITY Lane D — R40 §40.5: THE DOOR POINTS AND THE FOOTPATH (reusable gate). +// +// R39 found the shell's patronage door points read `(-sin ry, -cos ry)` — the BACK of the building — +// and measured 0 door points on a footpath corpus-wide (0/493, 0/72, 0/139, 0/30): peds ducking into +// the back fence since R8. Lane B landed the two-character `+sin/+cos` fix (web/index.html:433-437) +// in the same round; Lane D added the kerb clamp (web/js/citizens/door_snap.js, applied in +// sim.setShops) in R40. This gate makes the front-door truth un-regressable. +// +// WHAT IS MEASURED — each shop's door point, derived EXACTLY as the shell derives it (lot centre + +// front-normal · (d/2 + 0.6)), then passed through the SHIPPED snap (the same door_snap.js the sim +// imports — the gate tests the code path, not a copy). Classified against the plan's own geometry: +// +// ON FOOTPATH inside `vergeBand(e)` of SOME street edge (registry.js's published corridor +// law), on NO carriageway, inside NO lot. The R39 measure — reported for +// continuity. +// ON WALKABLE FRONT the gate's measure: ON FOOTPATH, or on the open ground of a pedestrian +// block (`market`/`arcade` block poly, outside every lot, off every +// carriageway). A market stall's front IS the market square — it can never be +// in a street's verge band and dragging its door 50 m to a street it does not +// front would re-break the truth this gate holds. "Duck in through the FRONT" +// means front ground a ped can stand on, which is what this classifies. +// +// GATE ARMS (all must hold): +// 1. walkable-front rate >= FLOOR_PCT (95) on all four R39 towns (synthetic seed 20261990 + +// katoomba_real + fitzroy_real + bowral_real); +// 2. CONTROL — the BACK point (the exact point shipped R8→R39) scores BELOW the floor on every +// town, or the classifier has gone vacuous and arm 1 means nothing; +// 3. FIX-ONLY — the snap breaks 0 points corpus-wide (every moved point classifies >= its raw). +// +// Run: node tools/qa/door_footpath_check.mjs [--json OUT] +// Exit: 0 green, 1 red. Plain node, zero deps, no browser; reads the plan generators + shipped town +// caches only — touches no game state, moves no goldens. + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generatePlan, generatePlanOSM, roadWidth, vergeBand } from '../../web/js/citygen/index.js'; +import { snapDoorToFootpath } from '../../web/js/citizens/door_snap.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const TOWNS = join(ROOT, 'web', 'assets', 'towns'); +const SEED = 20261990; +const FLOOR_PCT = 95; // §40.5's gate +const DOOR_STEP = 0.6; // the shell's kerbward nudge past the facade (index.html:434) +const EPS = 1e-6; + +let fails = 0; +const ok = (c, m) => { console.log(c ? ` \x1b[32m✓\x1b[0m ${m}` : ` \x1b[31m✗ FAIL\x1b[0m ${m}`); if (!c) fails++; }; +const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`); +const note = (m) => console.log(` \x1b[33m·\x1b[0m ${m}`); + +function dSeg(px, pz, ax, az, bx, bz) { + const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz; + if (L2 < EPS) return Math.hypot(px - ax, pz - az); + let t = ((px - ax) * dx + (pz - az) * dz) / L2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (ax + t * dx), pz - (az + t * dz)); +} +// strictly inside a lot's rotated rectangle (the boundary is not "in someone's yard") +function inLot(px, pz, l) { + const c = Math.cos(l.ry || 0), s = Math.sin(l.ry || 0); + const dx = px - l.x, dz = pz - l.z; + const lx = c * dx - s * dz, lz = s * dx + c * dz; // world→local (inverse of rot-by-ry) + return Math.abs(lx) < l.w / 2 - EPS && Math.abs(lz) < l.d / 2 - EPS; +} +function inPoly(px, pz, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [xi, zi] = poly[i], [xj, zj] = poly[j]; + if ((zi > pz) !== (zj > pz) && px < (xj - xi) * (pz - zi) / (zj - zi) + xi) inside = !inside; + } + return inside; +} + +function measure(plan, label) { + const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n])); + const lotById = new Map(plan.lots.map((l) => [l.id, l])); + const pedBlocks = (plan.blocks || []).filter((b) => b.kind === 'market' || b.kind === 'arcade'); + // sim-style edges — the exact shape _setGraph hands door_snap (A/B resolved, width/kind kept) + const simEdges = plan.streets.edges.map((e) => ({ ...e, A: nodeById.get(e.a), B: nodeById.get(e.b) })); + + const classify = (px, pz) => { + let onVerge = false, onCarr = false, minCentre = Infinity; + for (const e of plan.streets.edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); + if (!a || !b) continue; + const d = dSeg(px, pz, a.x, a.z, b.x, b.z); + if (d < minCentre) minCentre = d; + const inner = roadWidth(e) / 2, [vi, vo] = vergeBand(e); + if (d < inner - EPS) onCarr = true; + if (d >= vi - EPS && d <= vo + EPS && vo > vi + EPS) onVerge = true; + } + let insideLot = false; + for (const l of plan.lots) if (inLot(px, pz, l)) { insideLot = true; break; } + const foot = onVerge && !onCarr && !insideLot; + const plaza = !foot && !insideLot && !onCarr && pedBlocks.some((b) => inPoly(px, pz, b.poly)); + const walk = foot || plaza; + const why = walk ? (foot ? 'footpath' : 'plaza') : insideLot ? 'in-a-lot' : onCarr ? 'carriageway' : 'off-verge'; + return { foot, walk, why, minCentre }; + }; + + const out = { town: label, shops: 0, rawFoot: 0, rawWalk: 0, foot: 0, walk: 0, back: 0, + moved: 0, maxMove: 0, brokeBydSnap: 0, fixedBySnap: 0, why: {}, centre: [] }; + for (const s of plan.shops) { + const l = lotById.get(s.lot); if (!l) continue; + out.shops++; + const ry = l.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry), off = l.d / 2 + DOOR_STEP; + const rx = l.x + fx * off, rz = l.z + fz * off; // raw — the shell's derivation + const R = classify(rx, rz); + if (R.foot) out.rawFoot++; + if (R.walk) out.rawWalk++; + const p = snapDoorToFootpath(rx, rz, simEdges); // shipped — what sim.setShops stores + const S = p.moved ? classify(p.x, p.z) : R; + if (p.moved) { + out.moved++; out.maxMove = Math.max(out.maxMove, Math.hypot(p.x - rx, p.z - rz)); + if (R.walk && !S.walk) out.brokeBydSnap++; + if (!R.walk && S.walk) out.fixedBySnap++; + } + if (S.foot) out.foot++; + if (S.walk) out.walk++; else out.why[`${l.use || 'shop'}:${S.why}`] = (out.why[`${l.use || 'shop'}:${S.why}`] || 0) + 1; + const B = classify(l.x - fx * off, l.z - fz * off); // control — the R8–R39 defect point + if (B.walk) out.back++; + out.centre.push(S.minCentre); + } + out.centre.sort((a, b) => a - b); + out.median = out.centre.length ? +out.centre[(out.centre.length / 2) | 0].toFixed(2) : null; + delete out.centre; + const pct = (n) => out.shops ? +(100 * n / out.shops).toFixed(1) : 0; + out.rawFootPct = pct(out.rawFoot); out.rawWalkPct = pct(out.rawWalk); + out.footPct = pct(out.foot); out.walkPct = pct(out.walk); out.backPct = pct(out.back); + out.maxMove = +out.maxMove.toFixed(2); + return out; +} + +head(`DOOR POINT → FOOTPATH (floor ${FLOOR_PCT}% · door step ${DOOR_STEP} m · seed ${SEED})`); +const results = []; +results.push(measure(generatePlan(SEED), `synthetic@${SEED}`)); +for (const town of ['katoomba_real', 'fitzroy_real', 'bowral_real']) { + const cache = JSON.parse(readFileSync(join(TOWNS, `${town}.json`), 'utf8')); + results.push(measure(generatePlanOSM(SEED, town, { cache }), town)); +} + +for (const r of results) { + const whyStr = Object.entries(r.why).map(([k, v]) => `${k}:${v}`).join(' ') || '—'; + note(`${r.town}: raw foot ${r.rawFoot}/${r.shops} (${r.rawFootPct}%) walk ${r.rawWalk} (${r.rawWalkPct}%) → shipped foot ${r.foot} (${r.footPct}%) walk ${r.walk} (${r.walkPct}%)`); + note(` residue [${whyStr}] · snap moved ${r.moved} pt(s) max ${r.maxMove} m (fixed ${r.fixedBySnap}, broke ${r.brokeBydSnap}) · back arm ${r.back}/${r.shops} (${r.backPct}%) · median centreline dist ${r.median} m`); +} + +head('GATE'); +for (const r of results) { + ok(r.walkPct >= FLOOR_PCT, `${r.town}: door point on walkable front ground ${r.walk}/${r.shops} = ${r.walkPct}% (floor ${FLOOR_PCT}%)`); + ok(r.backPct < FLOOR_PCT, `${r.town}: CONTROL — back point scores ${r.backPct}% (must be < ${FLOOR_PCT}% or the classifier is vacuous)`); + ok(r.brokeBydSnap === 0, `${r.town}: FIX-ONLY — snap broke ${r.brokeBydSnap} previously-passing point(s) (must be 0)`); +} + +const ji = process.argv.indexOf('--json'); +if (ji > -1 && process.argv[ji + 1]) { + const { writeFileSync } = await import('node:fs'); + writeFileSync(process.argv[ji + 1], JSON.stringify({ seed: SEED, floorPct: FLOOR_PCT, doorStep: DOOR_STEP, results }, null, 2)); + note(`json → ${process.argv[ji + 1]}`); +} + +console.log(fails ? `\n\x1b[31mRED — ${fails} gate arm(s) failed\x1b[0m` : '\n\x1b[32mGREEN\x1b[0m'); +process.exit(fails ? 1 : 0); diff --git a/web/js/citizens/door_snap.js b/web/js/citizens/door_snap.js new file mode 100644 index 0000000..b3c83d2 --- /dev/null +++ b/web/js/citizens/door_snap.js @@ -0,0 +1,62 @@ +// PROCITY Lane D — R40 §40.5: door-point → footpath correction (pure geometry, no THREE, no rng). +// +// The shell derives each patronage door point as `lot centre + front-normal · (d/2 + 0.6)` +// (web/index.html:433-437, the +sin/+cos side since Lane B's R39 heading fix). That lands ON the +// footpath for almost every shop, but the residue class — lots whose setback or corner geometry +// puts the point a metre or two outside the verge band — left the R8 claim ("peds duck into shops +// they pass") false at the kerb: the ped steered to a point just off the walkable strip. +// +// This module clamps a door point into the footpath band of its NEAREST footpath-bearing street — +// registry.js's published `vergeBand` corridor law, over the same edge objects the sim already +// walks. Graph-only on purpose: the sim never sees lots, and this needs none. +// +// Guard rails (all measured before shipping — tools/qa/door_footpath_check.mjs is the gate): +// • CAP (default 6 m): a point further than this from its band is NOT a kerb miss — it is a +// market stall fronting the market square or an arcade shop fronting the covered lane. Those +// fronts are correct where they are (the square IS their front); dragging one 50 m to a street +// it does not front would re-break the front-door truth this exists to hold. Leave it. +// • MIN_BAND (0.8 m): a `lane` is all carriageway and an `arcade` band can degenerate — an edge +// with no walkable strip can never be a snap target. +// • MARGIN (0.45 m): snapped points sit inside the band, not on the kerb line, so a float wobble +// can't re-classify them. +// • Deterministic: pure function of (point, edges) — same plan → same doors, forever. +// +// Measured on the R39 four (raw → snapped, walkable-front rate): synthetic 95.9→95.9 (its residue +// is stall-vs-stall inside the square, out of this function's reach and over the floor anyway), +// katoomba 98.6→100, fitzroy 99.3→100, bowral 93.3→100 — and the transition check says it fixes 4 +// and breaks 0 corpus-wide. + +import { vergeBand } from '../core/registry.js'; + +const EPS = 1e-6; +export const DOOR_SNAP_CAP = 6; // m — beyond this the point fronts a plaza, not this street +export const DOOR_SNAP_MARGIN = 0.45; // m inside the band edges +export const DOOR_SNAP_MIN_BAND = 0.8; // m — narrower than this is not a walkable strip + +// edges: the sim's prepared edge list — [{ A:{x,z}, B:{x,z}, width, kind, ... }] (vergeBand reads +// width/kind; A/B are the resolved endpoints). Returns { x, z, moved } — the input point (moved: +// false) when it already sits in its nearest band, has no footpath edge to snap to, or is a +// plaza-front beyond the cap. +export function snapDoorToFootpath(x, z, edges, + { cap = DOOR_SNAP_CAP, margin = DOOR_SNAP_MARGIN, minBand = DOOR_SNAP_MIN_BAND } = {}) { + let best = null, bd = Infinity, bqx = 0, bqz = 0; + for (const e of edges) { + const [vi, vo] = vergeBand(e); + if (vo - vi < minBand) continue; // no walkable strip on this edge + const ax = e.A.x, az = e.A.z, dx = e.B.x - ax, dz = e.B.z - az; + const L2 = dx * dx + dz * dz; + let t = L2 < EPS ? 0 : ((x - ax) * dx + (z - az) * dz) / L2; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const qx = ax + t * dx, qz = az + t * dz; + const d = Math.hypot(x - qx, z - qz); + if (d < bd) { bd = d; best = e; bqx = qx; bqz = qz; } + } + if (!best || bd < EPS) return { x, z, moved: false }; // no footpath in town / on the centreline + const [vi, vo] = vergeBand(best); + const m = Math.min(margin, (vo - vi) / 2); + const target = Math.max(vi + m, Math.min(vo - m, bd)); // clamp into [kerb+m, corridor-edge−m] + const mv = Math.abs(target - bd); + if (mv < EPS || mv > cap) return { x, z, moved: false }; // already in band · or a plaza front + const ux = (x - bqx) / bd, uz = (z - bqz) / bd; // outward from the centreline + return { x: bqx + ux * target, z: bqz + uz * target, moved: true }; +} diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 6b9eccf..a2cac9e 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -16,6 +16,7 @@ import { rng, seedFor } from '../core/prng.js'; import { pickRig, makeActor } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; +import { snapDoorToFootpath } from './door_snap.js'; // ---- tuning (CITY_SPEC budgets) ---- const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor @@ -343,7 +344,26 @@ export class CitizenSim { // ---- R8 patronage + weather ---- // shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only). // map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert. - setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; } + // [Lane D R40 §40.5] Each door point is clamped into the footpath band of its nearest street + // (door_snap.js — registry's vergeBand over our own edges, capped at 6 m so plaza-front stalls + // stay put). The R8 claim is "peds duck into shops they pass"; the residue after B's R39 heading + // fix was door points a metre or two off the walkable strip — peds steering to the kerb gutter or + // a corner sliver. Points keep the shell's chunk keys (moves ≤ cap ≪ CHUNK; patronage is already + // chunk-blind by covenant, sim.js:401). Gated by tools/qa/door_footpath_check.mjs — ≥95% of door + // points on walkable front ground on all four measured towns, fix-only (breaks 0). + setShops(shopsByChunk) { + if (shopsByChunk && this.edges) { + const snapped = new Map(); + for (const [key, list] of shopsByChunk) { + snapped.set(key, list.map((s) => { + const p = snapDoorToFootpath(s.x, s.z, this.edges); + return p.moved ? { ...s, x: p.x, z: p.z } : s; + })); + } + shopsByChunk = snapped; + } + this.shopsByChunk = shopsByChunk; + } setPatronage(on) { this.patronageOn = !!on; } // Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street // converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants