PROCITY/pipeline/arcade_measure.mjs
m3ultra c2e44821c7 Lane E R39 (5/n): two corrections to my own R39 arithmetic, both measured
- The magpie's white, restated exactly. The wing-bar occlusion claim is the strong one and it now
  carries its footprint proof: the bar's (x 0.174-0.316, z 0.005-0.055) lies ENTIRELY inside the
  wing's (x 0-0.34, z -0.085-0.065) with the bar 3 mm above it, so from below the wing hides it.
  The nape box tops out at 0.067 against a head surface at 0.0728 (I had written 0.065, and had
  the sliver on the wrong side). And the flank argument is now the honest one: nape and rump are
  14 mm and 20 mm slabs on a 166 mm bird = 1.3 px and 2 px of white at 64 px.
- THE CLASSIC ARGUMENT FOR THE ARCADE IS INVERTED relative to R38 and I nearly carried the wrong
  one over. R38's ground palette is classic-safe BY ABSENCE (character.js returns frozen literals
  for a null town key; classic has none). The arcade district exists ONLY on the synthetic, and the
  synthetic IS the classic boot — so the kit lands squarely on the covenanted town and needs an
  EXPLICIT flag test, ground.js's VERGE shape, not the by-absence one.
- arcade_measure.mjs now also counts chunks: arcade lots span exactly 2 (0,0 and 0,1) on all five
  seeds, so a per-chunk arcade class is +1 draw in 2 chunks and +0 in the other 23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:58:26 +10:00

117 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// PROCITY Lane E — arcade_measure.mjs (R39, item 39.4)
//
// THE SIZING LAW, MADE RUNNABLE. R38 paid for the law with `paling_fence`: a perfectly good
// 1,121-tri panel became a REJECTED asset the moment Lane B counted the placement rule (5 runs ×
// 181 lots = 905 instances = 1.01 M triangles). The law is therefore: an asset's budget is
// `tris × the instance count the consuming lane's placement rule implies`, and you get that number
// BEFORE you generate. This script is that number for the arcade, so no later round has to trust a
// prose figure.
//
// node pipeline/arcade_measure.mjs [seed ...]
//
// Everything here is read out of Lane A's own `generatePlan` and Lane B's own constants; nothing
// is estimated. Constants mirrored from web/js/world/buildings.js are named at the point of use —
// if they move, this file is the second place to change (the R27 same-literal-in-two-files trap,
// declared rather than hidden).
import { generatePlan } from '../web/js/citygen/plan.js';
const AWNING_DEPTH = 2.2, AWNING_Y = 2.95, POST_INSET = 0.2; // buildings.js:21-22, :643
const FOOT = 3.5; // ground.js:14
const seeds = process.argv.slice(2).length ? process.argv.slice(2).map(Number)
: [20261990, 7, 123456, 999, 20260101];
const rows = [];
for (const s of seeds) {
const plan = generatePlan(s);
const arc = plan.districts.find((d) => d.kind === 'arcade');
const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const aEdges = plan.streets.edges.filter((e) => e.kind === 'arcade');
const len = aEdges.reduce((t, e) => {
const a = nodeById.get(e.a), b = nodeById.get(e.b);
return t + Math.hypot(b.x - a.x, b.z - a.z);
}, 0);
const width = aEdges[0].width;
const aBlocks = plan.blocks.filter((b) => b.district === arc.id);
const bIds = new Set(aBlocks.map((b) => b.id));
const lots = plan.lots.filter((l) => bIds.has(l.block));
const lotIds = new Set(lots.map((l) => l.id));
const shops = plan.shops.filter((sh) => lotIds.has(sh.lot));
const byType = {};
for (const sh of shops) byType[sh.type] = (byType[sh.type] || 0) + 1;
const skins = new Set(plan.shops.map((sh) => String(sh.facadeSkin).replace(/\.jpe?g$/, '').replace(/^facade-/, '')));
rows.push({
seed: s, len: +len.toFixed(2), width, blocks: aBlocks.length, lots: lots.length,
shops: shops.length, byType,
frontage: [Math.min(...lots.map((l) => l.w)), Math.max(...lots.map((l) => l.w))],
meanFrontage: lots.reduce((t, l) => t + l.w, 0) / lots.length,
depth: [Math.min(...lots.map((l) => l.d)), Math.max(...lots.map((l) => l.d))],
twoStorey: shops.filter((sh) => sh.storeys >= 2).length,
townShops: plan.shops.length, townLots: plan.lots.length,
distinctSkins: skins.size,
unmarked: Math.max(2, Math.min(12, Math.round(plan.shops.length * 0.06))),
});
}
const f = (n, d = 2) => Number(n).toFixed(d);
console.log('THE ARCADE, MEASURED (synthetic only — plan_osm.js:305 is one unconditional');
console.log('addDistrict("mainstreet"), so no cache town has an arcade DISTRICT. Real towns do have');
console.log('1,102 arcade-KIND edges = 39 km of ordinary suburban footpath: key any arcade dressing');
console.log('on district.kind, never on edge.kind. That is the v9 cut list as a code rule.\n');
console.log('seed lane blocks lots shops 2-storey frontage(m) depth(m) town shops skins unmarked');
for (const r of rows) {
console.log(`${String(r.seed).padEnd(11)} ${f(r.len)} m × ${r.width} m ${r.blocks} ${String(r.lots).padStart(2)} ${String(r.shops).padStart(2)} ${String(r.twoStorey).padStart(2)} `
+ `${f(r.frontage[0])}${f(r.frontage[1])}${f(r.meanFrontage)}) ${f(r.depth[0])}${f(r.depth[1])} ${String(r.townShops).padStart(4)} ${r.distinctSkins} ${r.unmarked}`);
}
const sh = rows.map((r) => r.shops);
console.log(`\nSHOPS PER ARCADE over ${rows.length} seeds: ${Math.min(...sh)}${Math.max(...sh)} (charter says 17; seed 20261990 = ${rows[0].shops})`);
console.log('types seen:', JSON.stringify(rows.reduce((a, r) => { for (const [k, v] of Object.entries(r.byType)) a[k] = (a[k] || 0) + v; return a; }, {})));
// ── the geometry the dressing has to live inside ────────────────────────────────────────────────
const half = rows[0].width / 2;
console.log('\nTHE LANE, from Lane B\'s own constants:');
console.log(` lot faces stand at ±${f(half)} m from the centreline ⇒ WALKABLE ${f(half * 2)} m between shopfronts`);
console.log(` buildings.js gives every use:'shop' lot an awning ${AWNING_DEPTH} m deep at y=${AWNING_Y} m, so the two`);
console.log(` slabs reach to ±${f(half - AWNING_DEPTH)} m ⇒ THE ARCADE IS ALREADY ROOFED EXCEPT A ${f((half - AWNING_DEPTH) * 2)} m SLOT`);
console.log(` down the centreline. Ceiling height is ${AWNING_Y} m — a hanging sign must clear EYE 1.62 and sit under it.`);
console.log(` ⚠ THE POSTS ARE IN THE WRONG PLACE. buildings.js:643 plants the two awning posts at`);
console.log(` d/2 + ${AWNING_DEPTH} ${POST_INSET} = ${f(AWNING_DEPTH - POST_INSET)} m out from the lot face, i.e. ±${f(half - (AWNING_DEPTH - POST_INSET))} m from the centreline —`);
console.log(` TWO ROWS OF POSTS ${f((half - (AWNING_DEPTH - POST_INSET)) * 2)} m APART DOWN THE MIDDLE OF A ${f(half * 2)} m LANE. They carry no collider`);
console.log(' (colliders.push(lotCollider(lot)) only), so the lane is walkable and the charter\'s 4.99 m');
console.log(' stands — but visually the arcade is a colonnade planted in its own doorway. AWNING_DEPTH');
console.log(' 2.2 was sized for a 3.5 m FOOT band, not a 2.5 m half-lane. → Lane B ask, see LANE_E_NOTES.');
console.log(` ground.js pave: the arcade branch emits ONE footpath quad ${f(rows[0].len)} × ${f(rows[0].width + FOOT * 2)} m`);
console.log(` = ${f(rows[0].len * (rows[0].width + FOOT * 2), 0)} m² — the single biggest surface in the district, and it wears footSkin today.`);
// ── how many CHUNKS hold arcade lots (a per-chunk class costs +1 draw in each) ───────────────────
{
const CHUNK = 64; // planutil.js CHUNK (CITY_SPEC law)
for (const s of seeds) {
const plan = generatePlan(s);
const arc = plan.districts.find((d) => d.kind === 'arcade');
const bIds = new Set(plan.blocks.filter((b) => b.district === arc.id).map((b) => b.id));
const keys = new Set(plan.lots.filter((l) => bIds.has(l.block))
.map((l) => `${Math.floor(l.x / CHUNK)},${Math.floor(l.z / CHUNK)}`));
console.log(` seed ${String(s).padEnd(9)} arcade lots span ${keys.size} chunk(s): ${[...keys].join(' ')}`);
}
}
// ── the budget line every arcade asset has to answer ────────────────────────────────────────────
console.log('\nINSTANCE COUNTS THE CONSUMING LANE IMPLIES (the number to generate against):');
const n = rows[0].shops;
const rows2 = [
['hanging blade sign', 'one per arcade shop', n, 'synthetic only'],
['shuttered facade skin', 'the dead tenant(s)', 1, '12 of the arcade\'s shops'],
['arcade floor skin', 'one merged ground class', 1, 'one town-wide mesh'],
['arcade roof/ceiling', 'one quad over the lane', 1, '12 chunks hold arcade lots'],
['A-frame board', 'arcade ~4 + v9 unmarked cue', 4 + rows[0].unmarked, `${4 + rows[0].unmarked}/town (unmarked = clamp(round(shops×0.06),2,12))`],
['key-cutter sign', 'the arcade\'s one trade sign', 1, 'landmark, not a repeat'],
];
for (const [what, rule, cnt, note] of rows2) {
console.log(` ${what.padEnd(22)} ${String(cnt).padStart(2)} × ${rule.padEnd(28)} ${note}`);
}
console.log('\nA GLB CANNOT RIDE boxMats (BoxGeometry + one shared untextured shell material), so each');
console.log('distinct GLB geometry is +1 draw. Ride it as ONE TOWN-WIDE InstancedMesh (magpie.js\'s');
console.log('pattern, count 0 or 1..N) and it is +1 total; make it a per-chunk furniture class and it is');
console.log('+1 × up to 25 live chunks, which at 8 draws of margin is a breach, not a cost.');