#!/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.`); // ── 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, '1–2 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, '1–2 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.');