#!/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);