Lane D R40 §40.5: the peds come in the front door — 100% on every real town

Verification first, and B's R39 heading fix did NOT clear the gate alone. Measured today
(tools/qa/door_footpath_check.mjs, new): synthetic 447/493 foot (90.7%) · katoomba 71/72 ·
fitzroy 138/139 · bowral 28/30 (93.3%) — from R39's 0/493, 0/72, 0/139, 0/30.

Two residue species, handled differently ON PURPOSE:
1. A 0.85m kerb miss on real towns — plan_osm's setback puts the facade past the corridor
   edge, so the shell's d/2+0.6 door point lands just off the verge. Fixed by new D-owned
   door_snap.js, applied in sim.setShops: clamps each door into the registry vergeBand of
   its nearest footpath-bearing edge, 6m cap. Corpus: fixes 4, breaks 0. Live-shell verified
   (493 points, 2 moved, max 5.41m — matches node exactly). Real towns now 72/72, 139/139, 30/30.
2. The synthetic's 36 strict misses are market stalls and arcade shops WHOSE FRONT IS THE
   MARKET SQUARE. An uncapped snap would 'pass' them by teleporting a door 59.85m to a street
   they do not front — passing the gate by breaking the truth it measures. The gate instead
   scores walkable front ground (footpath ∪ market/arcade block ground) = 473/493 (95.9%) and
   reports the strict number alongside.

The check ships its own falsifiability CONTROL (the R8-R39 back point must score under the
floor: reads 0.4-13.3%) and a fix-only arm (clamp breaks 0, asserted forever). Deterministic.
Remaining 20 synthetic misses are artefacts of the fixed nudge at index.html:433-437 — not
D's file; measured, exact fix documented for F. Goldens untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 12:28:25 +10:00
parent 066cc1775b
commit 3e1ff312a5
6 changed files with 380 additions and 1 deletions

View File

@ -1,5 +1,69 @@
# PROCITY-D — progress (Lane D · Citizens) # 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% → ~9199%) 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 5073 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 (5073 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
R8R39 defect) must score under the floor on every town or the classifier is vacuous (it reads
0.413.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) ## 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. Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points.

View File

@ -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, corridorEdge0.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, 5073 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.413.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` ## 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 **The ruling (Fable, on F's §36.4 held warn):** `b339402` (July 20) appended the 5 trellis'd DJ

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -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 R8R39 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);

View File

@ -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-edgem]
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 };
}

View File

@ -16,6 +16,7 @@ import { rng, seedFor } from '../core/prng.js';
import { pickRig, makeActor } from './rigs.js'; import { pickRig, makeActor } from './rigs.js';
import { makePlaceholder } from './placeholder.js'; import { makePlaceholder } from './placeholder.js';
import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js';
import { snapDoorToFootpath } from './door_snap.js';
// ---- tuning (CITY_SPEC budgets) ---- // ---- tuning (CITY_SPEC budgets) ----
const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor
@ -343,7 +344,26 @@ export class CitizenSim {
// ---- R8 patronage + weather ---- // ---- R8 patronage + weather ----
// shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only). // 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. // 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; } setPatronage(on) { this.patronageOn = !!on; }
// Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street // 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 // converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants