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>
166 lines
9.0 KiB
JavaScript
166 lines
9.0 KiB
JavaScript
#!/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);
|