PROCITY/web/js/world/buildings.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

944 lines
53 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.

// PROCITY Lane B — buildings.js
// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
// • merged door / window meshes (windows go emissive at night via applyNight)
// Everything is built in world space and collected, so a whole chunk is ~1018 draw calls.
//
// Canonical building: front face at local +Z, footprint centred at origin, then translate to the
// lot centre (x,z) and rotate by lot.ry (see fixture_plan.js orientation note).
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, pick, frange, irange } from '../core/prng.js';
import { SHOP_REGISTRY } from './fixture_plan.js';
import { SHOP_TYPES } from '../core/registry.js'; // [Lane B R41 §41.5] A's registry — the venue sign palettes
import { lotCollider } from './planutil.js';
// ── [Lane B R42 §42.5, filed by Lane F] THE FONT GATE BEFORE THE FIRST SIGN IS PAINTED ───────────
// The sign atlas fits its text by `measureText` (`drawSign`, below), so a cell laid out before the
// font is available gets different metrics and a different-looking sign. This is the one place the
// whole app can wait for that without making the chunk builder async: a module-level await runs ONCE
// and every importer — chunks.js → the shell → every `buildChunkBuildings` call — is downstream of
// it, so no atlas can be painted early. `buildChunkBuildings` stays synchronous and the streamer's
// 4 ms/frame budget is untouched.
//
// WHAT IT IS WORTH TODAY: nothing, and it is worth saying so rather than leaving a fix that reads
// like it closed something. F's R41 ruling blamed this race for four unreproducible bookmarks.
// Measured on this tree, 3 boots per class: `document.fonts.ready` resolves at 3851 ms and the
// FIRST canvas fillText in the process happens at 89109 ms — 4960 ms LATER, on 6 of 6 boots, with
// `document.fonts.status === 'loaded'` already. And `document.fonts.size === 0` at both moments,
// because the CSS Font Loading API only tracks FONT FACES and this game ships none (house law: all
// text is canvas, Arial is resolved by the platform). The atlas canvases hash IDENTICALLY across
// three boots (4149 canvases per bookmark, same sha1 list every time). The real cause of the
// unstable bookmarks is the crowd — see `dbg.js DBG.freeze()` and LANE_B_NOTES §42.
// It is kept because it is free (measured: +0 ms of added boot latency, §42) and because it is the
// invariant a future webfont would silently break.
await (typeof document !== 'undefined' && document.fonts ? document.fonts.ready : Promise.resolve());
const STOREY_H = 3.2;
const FACADE_H = 3.0; // ground-floor shopfront height the skin covers
const PARAPET_H = 0.5;
const AWNING_DEPTH = 2.2; // reach over the footpath
const AWNING_Y = 2.95;
const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890'];
const ROOF_TINT = '#5b5148';
const AWNING_SKINS = ['red', 'green', 'blue'];
// ── [Lane B R41 §41.5] THE VENUE SIGNAGE FIX — A's D3, filed to B in R40 ─────────────────────────
// The bite (this file, one line, `SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop`): `SHOP_REGISTRY`
// in fixture_plan.js carries NINE types and has never carried `pub`/`band_room`/`rsl`, so every gig
// venue in the game fell to op-shop paint (#2c2820 / #f0e2c0) — the same sign as the Vinnies two doors
// down, on the buildings the game photographs most.
//
// RE-MEASURED on today's tree, golden seed 20261990, gigs on, over the whole shipped corpus
// (synthetic + 3 osm fixtures + 23 town caches = 27 towns, 1,838 shops):
// **81 venue lots — 3 per town, in every town — all 81 taking the op-shop fallback.**
// A's R40 filing said 81; it reproduces exactly. (The bite has now drifted from `:344` (A, R37) to
// `:465` (A, R40) to `:483` — the line moves, the defect didn't, which is why the fix is a named
// resolver and not another literal at a line number.)
//
// ONE resolver, explicit precedence, and the op-shop arm is now genuinely last-resort:
// 1. `SHOP_REGISTRY[type]` — the nine palettes Lane B has shipped since v1. Returned unchanged, so
// every non-venue sign in the game is byte-identical to R40. Repainting those nine to A's
// registry palettes is an ART call (record would go muted-teal → hot-pink neon, stall dark →
// light), not a bug fix — filed for John/Fable in LANE_B_NOTES §41, deliberately not taken here.
// 2. `SHOP_TYPES[type].sign` — A's `core/registry.js`, which has carried gilt (pub) / stencil
// (band_room) / club (rsl) since R13 and is the single source of truth for any type A adds next.
// A NEW registry type can no longer arrive silently wearing op-shop paint.
// 3. `SHOP_REGISTRY.opshop` — a type in NEITHER table. Unreachable for anything the generator emits.
//
// `accent` exists ONLY on registry-sourced palettes, so the venue treatment below (accent frame +
// fascia band) is venue-gated BY CONSTRUCTION — the nine can't grow one by accident.
// Cost: zero draws, zero tris, zero fetches — it is two more `fillStyle`s in a canvas cell that was
// already being painted into the chunk's existing sign atlas. No golden can move: signage is a canvas
// texture, never a plan field (selfcheck re-run and stated in B-progress).
const SIGN_FALLBACK = SHOP_REGISTRY.opshop;
function signStyleFor(type) {
const own = SHOP_REGISTRY[type];
if (own) return own; // the nine — shipped bytes, untouched
const a = SHOP_TYPES[type] && SHOP_TYPES[type].sign; // the venues, and anything A adds next
if (a && a.bg && a.fg) return { signBg: a.bg, signFg: a.fg, accent: a.accent || null, style: a.style || null };
return SIGN_FALLBACK; // in neither registry — honestly unknown
}
// ── R26 #5 — the stocked shopfront (B's polish pick) ──────────────────────────────────────────────
// A crate-diggers' shop should read as one FROM THE FOOTPATH. The beta makes every keyed crate
// distinct *inside*; this closes the loop outside, so the player can spot a shop worth digging while
// walking past instead of door-roulette.
//
// THE MANIFEST IS THE AUTHORITY (ledger #6 — the same file F's wire consults). Deliberately NOT the
// `godverseShopId` key: keyed only means "has a stock identity", while the manifest means "a crate is
// actually here". Advertising a crate the shop hasn't got would make the street lie — the one thing
// this epoch keeps refusing to do. Fail-soft: no manifest (or ?noassets, or a shape we don't
// recognise) ⇒ no treatment ⇒ the street simply makes no promise. Never an error.
//
// The mark says "you can dig here", which is equally true of `real` and `mint` crates, so it does NOT
// leak tier — provenance belongs on C's price card and in the gates, not shouted from the footpath.
//
// Cost: ZERO extra draws / tris. The mark is drawn into the chunk's EXISTING sign-atlas cell and the
// window cue rides the EXISTING per-shop aTint attribute. Nothing new is allocated or submitted.
// The manifest is fetched LAZILY — only once a plan actually carries a keyed shop. A module-load fetch
// would have hit every boot: a pointless request (and, before G emits the file, a console 404) on the
// synthetic default, and a fetch delta on ?classic=1, whose covenant is zero-fetch. Godverse towns pay
// for it; nobody else knows it exists.
const STOCK_INDEX = 'assets/stock_godverse/index.json';
let STOCKED = null; // Set<godverseShopId> | null = "unknown" (⇒ no treatment)
let stockReq = null; // in-flight/settled marker — the fetch happens at most once per session
function ensureStockIndex() {
if (stockReq) return;
stockReq = (async () => {
try {
const p = new URLSearchParams(location.search);
if (p.get('noassets') != null && p.get('noassets') !== '0') return; // asset law: zero fetch
const r = await fetch(STOCK_INDEX, { cache: 'force-cache' });
if (!r.ok) return; // 404 before G emits it — fine
const j = await r.json();
// Tolerant of the exact shape (C pins §7 this round, G emits next wave): accept an array at
// .shops/.entries/.atlases or the root, of ids or of objects carrying godverseShopId|id.
const list = Array.isArray(j) ? j : (Array.isArray(j?.shops) ? j.shops
: Array.isArray(j?.entries) ? j.entries : Array.isArray(j?.atlases) ? j.atlases : null);
if (!list) return;
const set = new Set();
for (const e of list) {
const id = typeof e === 'number' ? e : (e && (e.godverseShopId ?? e.id));
if (Number.isSafeInteger(id) && id > 0) set.add(id);
}
if (set.size) STOCKED = set;
} catch { /* fail-soft by design */ }
})();
}
const isStocked = (shop) => !!(STOCKED && shop && Number.isSafeInteger(shop.godverseShopId) && STOCKED.has(shop.godverseShopId));
// Called once by the chunk manager at construction: start the manifest fetch as early as possible, but
// still ONLY for a plan that has keyed shops. Without this the fetch would start on the first keyed
// CHUNK — i.e. the first stocked shop you walk up to would be built before the manifest landed and
// would miss its mark. Synthetic / real / classic plans have no keyed shops ⇒ no request, ever.
export function prefetchStockIndex(plan) {
if ((plan?.shops || []).some((s) => Number.isSafeInteger(s && s.godverseShopId))) ensureStockIndex();
}
// ── Shop-hours facade state (§3.5): closed shops show dark windows + a CLOSED plate ─────────────
// ONE shared window material + ONE shared CLOSED-plate material across every chunk, driven by two
// shared uniforms that update ONCE on a day-segment change (via the 'procity:segment' event
// lighting.js dispatches — no per-frame poll). Per-shop open/closed rides on a per-vertex `aHours`
// (vec2 = the shop's [open,close] in 24h); the shader gates window emissive (open AND night) and
// plate alpha (closed). New chunks stream in already reflecting the current hour (shared uniforms).
const _hoursUniforms = { uHour: { value: 12 }, uNight: { value: 0 } };
let _windowMat = null, _plateMat = null, _plateTex = null;
function windowMaterial() {
if (_windowMat) return _windowMat;
const m = new THREE.MeshStandardMaterial({
color: 0x28323c, roughness: 0.14, metalness: 0.35, transparent: true, opacity: 0.46,
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 1.2,
});
m.onBeforeCompile = (shader) => {
shader.uniforms.uHour = _hoursUniforms.uHour;
shader.uniforms.uNight = _hoursUniforms.uNight;
shader.vertexShader = 'attribute vec2 aHours;\nvarying vec2 vHours;\n' +
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n\tvHours = aHours;');
shader.fragmentShader = 'uniform float uHour;\nuniform float uNight;\nvarying vec2 vHours;\n' +
shader.fragmentShader.replace('#include <emissivemap_fragment>',
'#include <emissivemap_fragment>\n\tfloat _open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n\ttotalEmissiveRadiance *= _open * uNight;');
};
_windowMat = m;
return m;
}
// ── v2: parallax interior-mapping window glass (?winmap=1, default-off) ──────────────────────────
// Single-cube interior mapping: each glass fragment casts the view ray into a virtual room box and
// shades the surface it hits (back wall + shelf silhouettes, side walls, floor/ceiling) — a plausible
// room seen through the window with real parallax, zero extra geometry. One SHARED ShaderMaterial for
// all windows (per-shop variation via aTint/aSeed attributes), same 1 draw/chunk as v1. Respects §3.5
// via the same uHour/uNight + aHours: closed-at-night rooms go dark, open rooms glow warm. Fully
// procedural (no fetch). Flag-off path is untouched (v1 windowMaterial()).
// [Lane F R16 — THE FLIP] winmap DEFAULT-ON; ?winmap=0 opts out, ?classic=1 forces off (the v2 covenant).
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); const classic = p.has('classic') && p.get('classic') !== '0'; return !classic && p.get('winmap') !== '0'; } catch (e) { return false; } })();
const ROOM_TINTS = [ // muted 90s interior wall colours (RGB 0..1), seeded per shop
[0.56, 0.44, 0.33], [0.42, 0.47, 0.52], [0.52, 0.47, 0.37], [0.47, 0.42, 0.46],
[0.54, 0.50, 0.41], [0.40, 0.45, 0.40], [0.58, 0.49, 0.40], [0.45, 0.48, 0.55],
];
// R26 #5: a stocked shop's window room reads warm-lamp-over-crates rather than a generic 90s wall —
// the second half of the footpath tell, and free (it rides the existing per-shop aTint attribute, so
// the winmap glass is still ONE shared material / one draw per chunk).
const STOCKED_ROOM_TINT = [0.50, 0.33, 0.22];
function roomTint(shop) {
if (isStocked(shop)) return STOCKED_ROOM_TINT;
return ROOM_TINTS[((shop ? shop.seed : 12345) >>> 0) % ROOM_TINTS.length];
}
let _winmapMat = null;
function interiorWindowMaterial() {
if (_winmapMat) return _winmapMat;
_winmapMat = new THREE.ShaderMaterial({
transparent: true,
uniforms: { uHour: _hoursUniforms.uHour, uNight: _hoursUniforms.uNight, uRoomDepth: { value: 0.85 } },
vertexShader: `
attribute vec2 aHours; attribute vec3 aTangent; attribute vec3 aTint; attribute float aSeed;
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
void main(){
vUv = uv; vWorldPos = position; vNormalW = normal; vTangentW = aTangent;
vHours = aHours; vTint = aTint; vSeed = aSeed;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
precision highp float;
uniform float uHour; uniform float uNight; uniform float uRoomDepth;
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
float h1(float n){ return fract(sin(n * 127.1) * 43758.5453); }
void main(){
vec3 N = normalize(vNormalW);
vec3 T = normalize(vTangentW);
vec3 Bb = normalize(cross(N, T));
vec3 rayDir = normalize(vWorldPos - cameraPosition);
vec3 rd = vec3(dot(rayDir, T), dot(rayDir, Bb), -dot(rayDir, N));
rd.z = max(rd.z, 1e-3);
vec2 uv = vUv;
float tz = uRoomDepth / rd.z; // back wall
float tx = (abs(rd.x) > 1e-4) ? (((rd.x > 0.0 ? 1.0 : 0.0) - uv.x) / rd.x) : 1e9;
float ty = (abs(rd.y) > 1e-4) ? (((rd.y > 0.0 ? 1.0 : 0.0) - uv.y) / rd.y) : 1e9;
if (tx <= 0.0) tx = 1e9; if (ty <= 0.0) ty = 1e9;
float t = min(tz, min(tx, ty));
vec3 hit = vec3(uv, 0.0) + rd * t;
float depth = clamp(hit.z / uRoomDepth, 0.0, 1.0);
vec3 wall = vTint;
vec3 col;
if (t >= tz - 1e-5) { // back wall + shelves
col = wall * 0.82;
for (int i = 0; i < 3; i++){
float fy = 0.22 + float(i) * 0.26 + (h1(vSeed + float(i) * 3.7) - 0.5) * 0.06;
float band = smoothstep(0.05, 0.03, abs(hit.y - fy));
col = mix(col, wall * 0.42, band * 0.7);
float bx = fract(hit.x * 6.0 + h1(vSeed + float(i)));
float prod = step(0.15, bx) * step(bx, 0.85) * step(fy, hit.y) * step(hit.y, fy + 0.11);
col = mix(col, wall * 1.3 + vec3(0.05, 0.03, 0.02), prod * 0.45);
}
} else if (t >= tx - 1e-5) {
col = wall * 0.6; // side wall
} else {
col = (rd.y < 0.0) ? wall * 0.5 : (wall * 1.05 + 0.04); // floor / ceiling
}
col *= mix(1.0, 0.55, depth); // depth falloff
float open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);
float lit = (uNight > 0.5) ? (open > 0.5 ? 1.0 : 0.10) : 0.72; // §3.5: closed@night dark
col *= lit;
if (uNight > 0.5 && open > 0.5) col = mix(col, col * vec3(1.0, 0.85, 0.6) * 1.7, 0.65); // warm glow
gl_FragColor = vec4(col, 0.94);
}`,
});
return _winmapMat;
}
function plateTexture() {
if (_plateTex) return _plateTex;
const c = document.createElement('canvas'); c.width = 256; c.height = 96;
const x = c.getContext('2d');
x.fillStyle = '#7a0f12'; x.fillRect(0, 0, 256, 96);
x.strokeStyle = '#e8c8a0'; x.lineWidth = 6; x.strokeRect(7, 7, 242, 82);
x.fillStyle = '#f0e2c0'; x.font = 'bold 52px Arial, sans-serif';
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('CLOSED', 128, 52);
_plateTex = new THREE.CanvasTexture(c); _plateTex.colorSpace = THREE.SRGBColorSpace;
_plateTex.generateMipmaps = false; _plateTex.minFilter = THREE.LinearFilter;
return _plateTex;
}
function closedPlateMaterial() {
if (_plateMat) return _plateMat;
_plateMat = new THREE.ShaderMaterial({
uniforms: { map: { value: plateTexture() }, uHour: _hoursUniforms.uHour },
transparent: true, depthWrite: false,
vertexShader: 'attribute vec2 aHours; varying vec2 vHours; varying vec2 vUv;\n'
+ 'void main(){ vHours = aHours; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
fragmentShader: 'uniform sampler2D map; uniform float uHour; varying vec2 vHours; varying vec2 vUv;\n'
+ 'void main(){ float openF = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n'
+ ' vec4 t = texture2D(map, vUv); float a = t.a * (1.0 - openF);\n'
+ ' if (a < 0.02) discard; gl_FragColor = vec4(t.rgb, a); }',
});
return _plateMat;
}
// Update the shared facade-hours uniforms (day-segment change → open/closed re-evaluates instantly).
export function setFacadeHours(hour, night) {
_hoursUniforms.uHour.value = hour;
_hoursUniforms.uNight.value = night ? 1 : 0;
}
if (typeof window !== 'undefined') {
window.addEventListener('procity:segment', (e) => { if (e.detail) setFacadeHours(e.detail.hour, !!e.detail.night); });
}
// ── R38 §1.4 — BACKYARD AUSTRALIA ────────────────────────────────────────────────────────────────
// The default boot ships **181 house and yard lots with nothing in them** (measured: 145 `house`,
// 36 `yard` on seed 20261990). Worse, a `yard` lot has fallen through to `buildShopfront()` since
// v1 — 36 lots A explicitly labels "yard" render as blank 3.2 m sheds with a door and two windows.
// This puts a Hills Hoist, a paling fence, a TV aerial and a rainwater tank in them.
//
// **IT COSTS ZERO DRAWS, and that is the whole reason it lives in this file.** Every prop below is
// pushed into the EXISTING `boxMats` list — the same per-chunk InstancedMesh that already carries
// shells, parapets and verandah posts — so a yard adds instances to a draw call that was already
// being submitted. A `world/yards.js` could not reach `boxMats` and would have paid +1 draw per live
// chunk (×31 at the dispose window) against a margin of 8. Triangles are the resource being spent
// here (12 per box), and triangles are the cheap one (~75k spare on the pinned frame).
//
// Everything is a primitive by construction, so `?noassets` is unaffected and there is nothing to
// upgrade-if-ready: a per-instance texture is impossible in a shared-material InstancedMesh, which
// is exactly the mechanism that makes it free. What E's asset wave upgrades is the ground under it
// (§1.2) and the sound in it (§1.1), not these boxes.
//
// SYNTHETIC-ONLY THIS ROUND, and stated rather than faked: all 21 real towns are 100% `use:'shop'`
// in one `mainstreet` district (charter ruling 2 — the civic fetch is a later round), so a real town
// has zero house/yard lots and zero of this fires there. It ships on the default boot, which is
// still the boot every player gets, and extends to real towns with no further code the moment A's
// third fetch class lands districts.
//
// `?yards=0` reverts to the pre-R38 geometry permanently (the `?verge=0` pattern — a falsifiability
// control that costs nothing and stays forever); `?classic=1` forces it off (the museum keeps v2).
const YARDS = (() => {
try {
const p = new URLSearchParams(location.search);
const classic = p.has('classic') && p.get('classic') !== '0';
return !classic && p.get('yards') !== '0';
} catch (e) { return false; } // unparseable search ⇒ pre-R38 geometry, never a throw
})();
// [Lane B R40 §40.4 — THE ARCADE KIT (E's placement rule, Fable-routed)] The one arcade lane is
// 42.00 × 5 m with lot faces at ±2.50 m, and the R39-era awning rule planted two rows of posts
// 1.00 m apart down its centreline — 34 colliderless posts standing in the lane's own doorway.
// The kit designs posts + roof as ONE: a real arcade's roof SPANS (slab depth 2.5 = the half-lane,
// so opposing slabs meet flush and the 0.60 m centre slot closes) AND A SPANNED ROOF HAS NO POSTS.
// No new geometry — deeper awning instances, deleted post instances; E measured +0 draws.
// The gate is EXPLICIT, not by-absence (R39's warning): the arcade district exists only on the
// synthetic and the synthetic IS the classic boot, so ungated this moves the golden render.
// `?classic=1` keeps today's geometry byte-identical; `?arcadekit=0` is the standing
// falsifiability control (the `?verge=0` / `?yards=0` idiom).
const ARCADEKIT = (() => {
try {
const p = new URLSearchParams(location.search);
const classic = p.has('classic') && p.get('classic') !== '0';
return !classic && p.get('arcadekit') !== '0';
} catch (e) { return false; } // unparseable search ⇒ pre-R40 geometry, never a throw
})();
// Measured against the picture, not the spec sheet: a paling fence and a Hills Hoist are within
// 30 cm of each other in real life, and the first cut had the fence TALLER (1.75) than the washing
// line's drop, which hid an entire street of back yards behind their own fences from every angle a
// player can stand at. An older AU paling fence is 1.51.8 m and a rotary hoist is adjustable to
// 2.02.4 m, so: fence 1.60, hoist 2.15. A 1.25 m sheet then hangs 0.902.15 and reads as half a
// metre of washing over the palings, which is exactly what you actually see walking past.
const FENCE_H = 1.60, FENCE_T = 0.09, FRONT_FENCE_H = 0.95, GATE_W = 1.2;
// HOIST_ARM below is the HALF-span. Capped at 1.75 ⇒ 3.5 m across, a real hoist. The first cut
// capped the same number at 3.2 and drew a 6.4 m machine in a 5 m yard.
const HOIST_H = 2.15, HOIST_ARM_T = 0.05, HOIST_ARM_MAX = 1.75;
const TANK_H = 1.9, TANK_D = 1.55;
const AERIAL_H = 1.35;
const PALING_TINTS = ['#9c8c72', '#a4967c', '#8e8068', '#ab9c80', '#93866e'];
const GALV = '#aeb4b6'; // galvanised: hoist, aerial, tank bands
const TANK_TINTS = ['#8d968c', '#a2a49a', '#7f8b86'];
// The seed a house/yard lot has used since v1 — kept EXACTLY as `buildHouse` computed it so nothing
// re-rolls, and lifted out so the washing layer (a town-wide mesh in washing.js) can derive the same
// hoist from the same numbers instead of inventing a second placement rule. Two implementations of
// one thing is the disease this project keeps catching itself with; one exported pure function is
// the cure.
export function yardSeed(lot, citySeed) {
return (citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
}
/**
* yardLayout(lot, citySeed) → the whole back yard as plain numbers, in LOT-LOCAL coordinates
* (+Z = the street-facing front, matching the canonical building convention at the top of this file).
* Pure, allocation-light, no THREE. Both the builder below and washing.js consume it.
*/
export function yardLayout(lot, citySeed) {
const seed = yardSeed(lot, citySeed);
const r = rng(seed, 'yard', 0);
const w = lot.w, d = lot.d;
const house = lot.use === 'house';
// The dwelling is pulled off its boundaries: a small front garden, side gaps a wheelie bin fits
// down, and the REST IS BACK YARD. Pre-R38 the shell was the whole lot (w × d), which is why 145
// houses had no yard to put anything in.
const frontSet = house ? 1.25 : 0;
const hw = house ? Math.max(3.5, w * 0.84) : 0;
const hd = house ? Math.max(3.0, d * 0.60) : 0;
const houseZ = house ? (d / 2 - frontSet - hd / 2) : 0; // local z of the dwelling centre
const yardBackZ = -d / 2 + FENCE_T; // inside face of the back fence
const yardFrontZ = house ? (houseZ - hd / 2) : (d / 2 - FENCE_T);
const yardDepth = Math.max(0.8, yardFrontZ - yardBackZ);
const hoistZ = yardBackZ + yardDepth * (house ? 0.55 : 0.5);
// Rotary hoist: HALF-span, fits the yard it stands in and never overhangs the fence.
const arm = Math.min(HOIST_ARM_MAX, Math.max(0.85, Math.min(w * 0.30, yardDepth * 0.42)));
const hasTank = r() < (house ? 0.36 : 0.55); // tanks cluster on the sheds
const hasAerial = house && r() < 0.72; // not every roof kept its aerial
const tankSide = r() < 0.5 ? -1 : 1;
const shedW = house ? 0 : Math.min(3.4, w * 0.52);
const shedD = house ? 0 : Math.min(2.8, yardDepth * 0.46);
return {
seed, house, w, d,
houseW: hw, houseD: hd, houseZ,
fenceH: FENCE_H, palingTint: PALING_TINTS[seed % PALING_TINTS.length],
hoist: { x: 0, z: hoistZ, h: HOIST_H, arm, yaw: (seed >>> 7) % 90 * (Math.PI / 180) },
tank: hasTank ? { x: tankSide * (w / 2 - TANK_D / 2 - 0.28), z: yardBackZ + TANK_D / 2 + 0.2, h: TANK_H, d: TANK_D,
tint: TANK_TINTS[(seed >>> 11) % TANK_TINTS.length] } : null,
aerial: hasAerial ? { x: (r() - 0.5) * hw * 0.5, z: houseZ - hd * 0.18, h: AERIAL_H } : null,
shed: shedW > 0 ? { x: (r() - 0.5) * (w - shedW - 0.6), z: yardBackZ + shedD / 2 + 0.15, w: shedW, d: shedD, h: 2.2 } : null,
};
}
/**
* hoistAnchors(plan, citySeed) → [{ x, z, y, arm, yaw }] in world space, one per Hills Hoist in the
* town, in plan order. The town-wide washing layer pegs onto these. Pure function of the plan +
* seed, derived from the SAME yardLayout the geometry is built from, so a line of washing can never
* hang in mid-air next to a hoist that moved. Empty on every real town today (no house/yard lots).
*/
export function hoistAnchors(plan, citySeed) {
const out = [];
for (const lot of (plan.lots || [])) {
if (lot.use !== 'house' && lot.use !== 'yard') continue;
const L = yardLayout(lot, citySeed >>> 0);
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
out.push({
lotId: lot.id,
x: lot.x + (L.hoist.x * cos + L.hoist.z * sin),
z: lot.z + (-L.hoist.x * sin + L.hoist.z * cos),
y: L.hoist.h, arm: L.hoist.arm, yaw: (lot.ry || 0) + L.hoist.yaw, seed: L.seed,
});
}
return out;
}
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
const _s = new THREE.Vector3();
const _p = new THREE.Vector3();
// Build a flat quad (two tris) from four world-space corners + a UV rect. Corners CCW seen from +Y-ish.
function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
const g = new THREE.BufferGeometry();
const pos = new Float32Array([
c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2],
c0[0], c0[1], c0[2], c2[0], c2[1], c2[2], c3[0], c3[1], c3[2],
]);
const [u0, v0, u1, v1] = uv;
const uvs = new Float32Array([u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1]);
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
// quad + a per-vertex `aHours` (the shop's [open,close]) so the shared window/plate shaders can
// gate emissive / alpha per shop without per-shop materials.
function hoursQuad(c0, c1, c2, c3, hours, uv) {
const g = quad(c0, c1, c2, c3, uv);
const h = new Float32Array(12);
for (let i = 0; i < 6; i++) { h[i * 2] = hours[0]; h[i * 2 + 1] = hours[1]; }
g.setAttribute('aHours', new THREE.BufferAttribute(h, 2));
return g;
}
// A window quad. Under ?winmap it carries the extra interior-mapping attributes (aTangent = the
// window's world-space right vector, aTint = the seeded room colour, aSeed = shelf variation). With
// the flag off it is exactly the v1 hoursQuad, so flag-off is byte-identical.
function windowQuad(c0, c1, c2, c3, hours, shop) {
const g = hoursQuad(c0, c1, c2, c3, hours);
if (!WINMAP) return g;
const tx = c1[0] - c0[0], ty = c1[1] - c0[1], tz = c1[2] - c0[2];
const tl = Math.hypot(tx, ty, tz) || 1;
const tan = [tx / tl, ty / tl, tz / tl];
const tint = roomTint(shop);
const seed = shop ? ((shop.seed >>> 0) % 997) / 997 : 0.5;
const tanA = new Float32Array(18), tintA = new Float32Array(18), seedA = new Float32Array(6);
for (let i = 0; i < 6; i++) {
tanA[i * 3] = tan[0]; tanA[i * 3 + 1] = tan[1]; tanA[i * 3 + 2] = tan[2];
tintA[i * 3] = tint[0]; tintA[i * 3 + 1] = tint[1]; tintA[i * 3 + 2] = tint[2];
seedA[i] = seed;
}
g.setAttribute('aTangent', new THREE.BufferAttribute(tanA, 3));
g.setAttribute('aTint', new THREE.BufferAttribute(tintA, 3));
g.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
return g;
}
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
function toWorld(lot, lx, ly, lz, out) {
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
out[0] = lot.x + (lx * cos + lz * sin);
out[1] = ly;
out[2] = lot.z + (-lx * sin + lz * cos);
return out;
}
/**
* buildChunkBuildings(chunkData, ctx) → chunk building bundle.
* chunkData: { lots:[], shops:[] }
* ctx: { skins, citySeed, night }
* returns { group, colliders, doorMesh|null, doorRects, applyNight, dispose }
*/
export function buildChunkBuildings(chunkData, ctx) {
const { skins } = ctx;
const group = new THREE.Group();
group.name = 'chunk-buildings';
// R26 #5: ask for the stock manifest only if this town has keyed shops at all (once per session).
// Synthetic / real / classic boots never carry a godverseShopId, so they never make the request.
if ((chunkData.shops || []).some((s) => Number.isSafeInteger(s && s.godverseShopId))) ensureStockIndex();
const boxMats = []; // {matrix, color} — shells + parapets + posts, ONE instanced draw/chunk
const awnings = []; // {matrix, color} — all awning/canopy skins, ONE instanced draw/chunk
const facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
const doorGeos = []; // interactive shop/stall doors (raycast targets)
const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
const windowGeos = []; // all windows merge into 1 mesh (shared material, per-vertex aHours)
const plateGeos = []; // CLOSED plates, shown per-shop by the shared plate shader (closed → visible)
const signQuads = []; // { geo }
const colliders = [];
const doorRects = []; // { shopId, name, x, z, hours }
const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
// ── sign atlas: lay every sign in this chunk into one 2048² canvas ──
// Atlas sized to the chunk's ACTUAL sign count (not a fixed 2048²): one row per 4 signs, so a
// typical chunk holds a ~2048×168 strip, not a 16MB square. Mipmaps off (saves ⅓ GPU + NPOT-safe).
const COLS = 4, CELL_W = 512, CELL_H = 168, ATLAS_W = COLS * CELL_W; // 2048 wide
const N_SIGNS = chunkData.shops.length; // shops+stalls each get one
const ATLAS_ROWS = Math.max(1, Math.ceil(N_SIGNS / COLS));
const ATLAS_H = ATLAS_ROWS * CELL_H;
let atlasCanvas = null, actx = null; // still lazy — shopless chunks allocate nothing
let signCell = 0;
const signUV = new Map(); // shopId → [u0,v0,u1,v1]
function drawSign(shop) {
if (signCell >= COLS * ATLAS_ROWS) return null;
if (!atlasCanvas) {
atlasCanvas = document.createElement('canvas');
atlasCanvas.width = ATLAS_W; atlasCanvas.height = ATLAS_H;
actx = atlasCanvas.getContext('2d');
actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
}
const col = signCell % COLS, row = (signCell / COLS) | 0;
const px = col * CELL_W, py = row * CELL_H;
const reg = signStyleFor(shop.type); // [Lane B R41 §41.5] see the resolver at the top of this file
actx.fillStyle = reg.signBg; actx.fillRect(px + 4, py + 4, CELL_W - 8, CELL_H - 8);
actx.fillStyle = reg.signFg;
actx.strokeStyle = reg.signFg; actx.lineWidth = 3;
actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
// [Lane B R41 §41.5] THE VENUE TREATMENT — a licensed house wears its own paint: the frame goes to
// the registry's `accent` and a fascia band runs the top inside edge (a pub's painted head-board).
// Only registry-sourced palettes carry `accent`, so this is venue-only by construction, and the
// band sits at the TOP so it can never collide with the R26 crate mark at the sign's foot.
if (reg.accent) {
actx.strokeStyle = reg.accent;
actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
actx.fillStyle = reg.accent;
actx.fillRect(px + 14, py + 14, CELL_W - 28, 9);
actx.fillStyle = reg.signFg; // the name is still painted in `fg`
}
// fit text
let fs = 74; const label = String(shop.name).slice(0, 22).toUpperCase();
actx.textAlign = 'center'; actx.textBaseline = 'middle';
do { actx.font = `bold ${fs}px Arial, sans-serif`; fs -= 4; }
while (actx.measureText(label).width > CELL_W - 40 && fs > 22);
actx.fillText(label, px + CELL_W / 2, py + CELL_H / 2);
if (isStocked(shop)) drawCrateMark(px, py, shop); // R26 #5 — same cell, no extra draw
const uv = [px / ATLAS_W, 1 - (py + CELL_H) / ATLAS_H, (px + CELL_W) / ATLAS_W, 1 - py / ATLAS_H];
signUV.set(shop.id, uv);
signCell++;
return uv;
}
// A crate of records seen end-on, along the sign's foot: the shop is worth digging. Seeded off
// shop.seed so two stocked shops don't wear the same spines (the beta's whole theme, on the
// street). Pure canvas into the cell already being drawn ⇒ zero draws, zero tris.
const SPINES = ['#2f2a26', '#7a2f28', '#2c4553', '#6b5a2e', '#3f2f45', '#8a6a3a', '#26463a'];
function drawCrateMark(px, py, shop) {
const y = py + CELL_H - 40, h = 24;
const x0 = px + 30, x1 = px + CELL_W - 30;
actx.fillStyle = 'rgba(0,0,0,0.30)'; // the crate itself, in shadow
actx.fillRect(x0 - 7, y - 5, (x1 - x0) + 14, h + 10);
let r = ((shop.seed >>> 0) || 1) ^ 0x5bf03635;
const rnd = () => ((r = (r * 1664525 + 1013904223) >>> 0) / 4294967296);
for (let x = x0; x < x1 - 3; x += 6) { // the spines: leaning, uneven, dug-through
const hh = h - ((rnd() * 7) | 0);
actx.fillStyle = SPINES[(rnd() * SPINES.length) | 0];
actx.fillRect(x, y + (h - hh), 4, hh);
}
}
// ── R38 §1.4 helpers: every yard prop is ONE more instance in the EXISTING boxMats list ────────
// (see the header block above — this is the +0-draw mechanism, not a convenience.) Declared
// BEFORE the per-lot loop on purpose: the scratch vectors are `const`, and a hoisted function that
// closes over a `const` declared after its first call would hit the temporal dead zone.
const _yq = new THREE.Quaternion();
const _yp = new THREE.Vector3();
const _ys = new THREE.Vector3();
function yardBox(lot, lx, ly, lz, sx, sy, sz, color, extraYaw = 0) {
const ry = (lot.ry || 0);
const cos = Math.cos(ry), sin = Math.sin(ry);
_yp.set(lot.x + (lx * cos + lz * sin), ly, lot.z + (-lx * sin + lz * cos));
_yq.setFromAxisAngle(_up, ry + extraYaw);
_ys.set(sx, sy, sz);
boxMats.push({ matrix: new THREE.Matrix4().compose(_yp, _yq, _ys), color: new THREE.Color(color) });
}
// Paling fence around a lot: the two sides and the back at full height, the street frontage a low
// picket split around a gate gap. 5 boxes = 60 tris for a fenced block of land.
function fenceRuns(lot, L) {
const w = L.w, d = L.d, t = FENCE_T, h = L.fenceH, c = L.palingTint;
yardBox(lot, 0, h / 2, -d / 2 + t / 2, w, h, t, c); // back
yardBox(lot, -w / 2 + t / 2, h / 2, 0, t, h, d, c); // side
yardBox(lot, w / 2 - t / 2, h / 2, 0, t, h, d, c); // side
const fw = (w - GATE_W) / 2;
if (fw > 0.3) {
yardBox(lot, -(GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c);
yardBox(lot, (GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c);
}
}
// Hills Hoist: mast + two crossed arms = four arms in 3 boxes. The washing (washing.js, town-wide,
// its own draw) pegs onto the SAME arms via hoistAnchors(), which reads the same yardLayout.
function hillsHoist(lot, L) {
const H = L.hoist;
yardBox(lot, H.x, H.h / 2, H.z, 0.075, H.h, 0.075, GALV);
yardBox(lot, H.x, H.h, H.z, H.arm * 2, HOIST_ARM_T, HOIST_ARM_T, GALV, H.yaw);
yardBox(lot, H.x, H.h - 0.03, H.z, HOIST_ARM_T, HOIST_ARM_T, H.arm * 2, GALV, H.yaw);
}
// Rainwater tank: two boxes at 45° read as a round corrugated tank in silhouette for 24 tris.
function waterTank(lot, L) {
const T = L.tank; if (!T) return;
yardBox(lot, T.x, T.h / 2, T.z, T.d, T.h, T.d, T.tint);
yardBox(lot, T.x, T.h / 2, T.z, T.d * 0.92, T.h * 0.98, T.d * 0.92, T.tint, Math.PI / 4);
}
// TV aerial on the ridge: mast + two elements.
function tvAerial(lot, L, roofTopY) {
const A = L.aerial; if (!A) return;
yardBox(lot, A.x, roofTopY + A.h / 2, A.z, 0.05, A.h, 0.05, GALV);
yardBox(lot, A.x, roofTopY + A.h * 0.92, A.z, 1.15, 0.045, 0.045, GALV);
yardBox(lot, A.x, roofTopY + A.h * 0.66, A.z, 0.85, 0.045, 0.045, GALV);
}
// ── per-lot build ──
for (const lot of chunkData.lots) {
const shop = shopByLot.get(lot.id);
if (lot.use === 'house') { buildHouse(lot); continue; }
// [R38 §1.4] a `yard` lot is a YARD. Pre-R38 it fell through to buildShopfront() and rendered as
// a blank 3.2 m shed with a door and two windows — 36 of them on the default boot. Under
// ?yards=0 / ?classic=1 it keeps falling through, byte-identically.
if (lot.use === 'yard' && YARDS) { buildYardLot(lot); continue; }
if (lot.use === 'stall') { buildStall(lot, shop); continue; }
buildShopfront(lot, shop);
}
function buildShopfront(lot, shop) {
const seed = shop ? shop.seed : ctx.citySeed ^ 0x9e37;
const r = rng(seed, 'bld', 0);
const storeys = (shop && shop.storeys) || 1;
const h = storeys * STOREY_H;
const w = lot.w, d = lot.d;
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
// shell (unit box scaled) centred at (x, h/2, z)
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC.clone() });
// parapet cap along the front
_p.set(lot.x, h + PARAPET_H / 2, lot.z); _s.set(w, PARAPET_H, d * 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// facade plane on the ground-floor front (local +Z face) — UV-mapped into the shared facade atlas
const skin = (shop && shop.facadeSkin) || 'weatherboard';
const fh = Math.min(h, FACADE_H);
const zf = d / 2 + 0.02;
const hw = w * 0.48;
const c0 = toWorld(lot, -hw, 0.02, zf, []);
const c1 = toWorld(lot, hw, 0.02, zf, []);
const c2 = toWorld(lot, hw, fh, zf, []);
const c3 = toWorld(lot, -hw, fh, zf, []);
facadeGeos.push(quad(c0, c1, c2, c3, skins.facadeAtlasUV(skin)));
// door (dark) — centred, on the front, proud of the facade
const dw = 1.2, dh = 2.2, zd = zf + 0.03;
doorGeos.push(quad(
toWorld(lot, -dw / 2, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, zd, []),
));
const hrs = (shop && shop.hours) || [0, 24]; // infill w/o a shop → always "open" (lit at night)
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, hours: shop.hours, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
// windows flanking the door (tinted glass, emissive when OPEN at night — per-shop via aHours)
const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
for (const ox of offs) {
windowGeos.push(windowQuad(
toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
hrs, shop,
));
}
// CLOSED plate over the shopfront — visible only when the shop is shut (shader gates on aHours)
if (shop) {
const pw = Math.min(1.5, w * 0.4), ph = 0.5, py = 1.35, zp = zf + 0.06;
plateGeos.push(hoursQuad(
toWorld(lot, -pw / 2, py - ph / 2, zp, []), toWorld(lot, pw / 2, py - ph / 2, zp, []),
toWorld(lot, pw / 2, py + ph / 2, zp, []), toWorld(lot, -pw / 2, py + ph / 2, zp, []),
shop.hours || [0, 24],
));
}
// sign band overlaying the blank signboard region of the skin
if (shop) {
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
const sh = 0.5, sy = fh - 0.35, sw = w * 0.92, zs = zf + 0.05;
signQuads.push(quad(
toWorld(lot, -sw / 2, sy - sh / 2, zs, []), toWorld(lot, sw / 2, sy - sh / 2, zs, []),
toWorld(lot, sw / 2, sy + sh / 2, zs, []), toWorld(lot, -sw / 2, sy + sh / 2, zs, []),
uv,
));
}
}
// posted verandah + awning over the footpath (main-street shopfronts only)
if (lot.use === 'shop' || lot.use === 'anchor') {
// [Lane B R40 §40.4 — THE ARCADE KIT] in an arcade DISTRICT the roof spans the lane (2.5 =
// the half-lane, opposing slabs meet flush on the centreline) and a spanned roof has no
// posts. Everywhere else: the unchanged posted verandah. See ARCADEKIT above for the gate.
const inArcade = ARCADEKIT && ctx.arcadeBlocks && ctx.arcadeBlocks.has(lot.block);
const depth = inArcade ? 2.5 : AWNING_DEPTH;
const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
// awning box: thin slab reaching over the footpath at the front
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + depth / 2, [])));
_s.set(w, 0.16, depth);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// two posts at the awning's outer edge — never inside a spanned arcade
if (!inArcade) {
const postY = AWNING_Y / 2;
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
_s.set(0.12, AWNING_Y, 0.12);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
}
}
}
colliders.push(lotCollider(lot));
}
function buildHouse(lot) {
const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
const r = rng(seed, 'house', 0);
const h = 2.7;
// [R38 §1.4] Under the ruling the dwelling is inset off its boundaries (front garden, side
// gaps, and A BACK YARD); with ?yards=0 / ?classic=1 it is the whole lot, exactly as since v1.
const L = YARDS ? yardLayout(lot, ctx.citySeed >>> 0) : null;
const w = L ? L.houseW : lot.w, d = L ? L.houseD : lot.d;
const cz = L ? L.houseZ : 0; // local z of the dwelling centre
const wx = lot.x + (cz * Math.sin(lot.ry || 0)), wz = lot.z + (cz * Math.cos(lot.ry || 0));
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
_p.set(wx, h / 2, wz); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC });
// simple hip roof: a squashed, slightly oversized box on top
_p.set(wx, h + 0.5, wz); _s.set(w + 0.6, 1.0, d + 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// front door + two windows (decorative — houses are not enterable, so keep the door out of
// the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop)
const zf = cz + d / 2 + 0.02;
decorDoorGeos.push(quad(
toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, zf, []),
toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
));
for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
windowGeos.push(windowQuad(
toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
[0, 24], null, // homes glow at night (always "open")
));
}
// ── the yard itself: fence, hoist, tank, aerial — all instances in the EXISTING boxMats draw ──
if (L) { fenceRuns(lot, L); hillsHoist(lot, L); waterTank(lot, L); tvAerial(lot, L, h + 1.0); }
colliders.push(lotCollider(lot)); // unchanged: the collider is the LOT, i.e. the fence line
}
// [R38 §1.4] A `yard` lot: fenced block, Hills Hoist, a fibro shed, often a rainwater tank. No
// shell, no parapet, no facade, no door, no windows — which is why replacing the blank shed this
// lot used to render as costs a net ~100 tris rather than ~230, and removes a decorative door
// from a lot nobody could ever walk into. Collider unchanged (the lot rect = the fence line), so
// player collision on these 36 lots is byte-identical to pre-R38.
function buildYardLot(lot) {
const L = yardLayout(lot, ctx.citySeed >>> 0);
fenceRuns(lot, L);
hillsHoist(lot, L);
waterTank(lot, L);
if (L.shed) {
yardBox(lot, L.shed.x, L.shed.h / 2, L.shed.z, L.shed.w, L.shed.h, L.shed.d, '#9aa0a2');
yardBox(lot, L.shed.x, L.shed.h + 0.12, L.shed.z, L.shed.w + 0.35, 0.16, L.shed.d + 0.35, ROOF_TINT);
}
colliders.push(lotCollider(lot));
}
function buildStall(lot, shop) {
const seed = shop ? shop.seed : 4242;
const w = lot.w, d = lot.d;
// trestle table
_p.set(lot.x, 0.4, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, 0.8, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#6b4a30') });
// striped canopy (awning skin) above
const askin = AWNING_SKINS[seed % AWNING_SKINS.length];
_p.set(lot.x, 2.2, lot.z); _s.set(w + 0.6, 0.14, d + 0.8);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// corner posts
for (const [ox, oz] of [[-w / 2, -d / 2], [w / 2, -d / 2], [-w / 2, d / 2], [w / 2, d / 2]]) {
_p.fromArray(toWorld(lot, ox, 1.1, oz, [])); _s.set(0.08, 2.2, 0.08);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#3a3a3a') });
}
// interaction plane on the front so you can "enter" the stall
if (shop) {
const zf = d / 2 + 0.02;
doorGeos.push(quad(
toWorld(lot, -w / 2, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, zf, []),
));
doorRects.push({ shopId: shop.id, name: shop.name, hours: shop.hours, x: lot.x, z: lot.z });
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
signQuads.push(quad(
toWorld(lot, -w / 2, 2.0, zf + 0.05, []), toWorld(lot, w / 2, 2.0, zf + 0.05, []),
toWorld(lot, w / 2, 2.4, zf + 0.05, []), toWorld(lot, -w / 2, 2.4, zf + 0.05, []),
uv,
));
}
// CLOSED plate on the stall front
const pw = Math.min(1.4, w * 0.6);
plateGeos.push(hoursQuad(
toWorld(lot, -pw / 2, 1.0, zf + 0.06, []), toWorld(lot, pw / 2, 1.0, zf + 0.06, []),
toWorld(lot, pw / 2, 1.45, zf + 0.06, []), toWorld(lot, -pw / 2, 1.45, zf + 0.06, []),
shop.hours || [0, 24],
));
}
colliders.push(lotCollider(lot));
}
// ── finalise instanced meshes ──
const disposables = [];
function makeInstanced(list, material, castShadow) {
if (!list.length) return null;
const geo = new THREE.BoxGeometry(1, 1, 1);
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) {
im.setMatrixAt(i, list[i].matrix);
if (list[i].color && im.instanceColor !== undefined) im.setColorAt(i, list[i].color);
}
im.instanceMatrix.needsUpdate = true;
if (im.instanceColor) im.instanceColor.needsUpdate = true;
im.castShadow = !!castShadow; im.receiveShadow = true;
im.frustumCulled = true;
disposables.push(geo);
group.add(im);
return im;
}
makeInstanced(boxMats, skins.shellMaterial(), true); // shells + parapets + posts (per-instance colour)
makeInstanced(awnings, skins.awningMaterial(), true); // every awning/canopy skin (per-instance colour)
// ── finalise the facade: ALL skins merge into ONE mesh via the shared atlas (1 draw/chunk) ──
if (facadeGeos.length) {
const merged = mergeGeometries(facadeGeos, false);
facadeGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, skins.facadeAtlasMaterial());
mesh.receiveShadow = true; mesh.castShadow = false;
disposables.push(merged);
group.add(mesh);
}
// ── doors ──
const doorMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
disposables.push(doorMat);
// interactive shop/stall doors — the HUD raycasts this mesh and maps hits to doorRects
let doorMesh = null;
if (doorGeos.length) {
const merged = mergeGeometries(doorGeos, false);
doorGeos.forEach((g) => g.dispose());
doorMesh = new THREE.Mesh(merged, doorMat);
doorMesh.userData.isDoorMesh = true;
doorMesh.userData.doorRects = doorRects;
disposables.push(merged);
group.add(doorMesh);
}
// decorative house doors — same look, but no userData so the HUD never picks them
if (decorDoorGeos.length) {
const merged = mergeGeometries(decorDoorGeos, false);
decorDoorGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, doorMat);
disposables.push(merged);
group.add(mesh);
}
// ── windows (merged, ONE shared material; emissive gated per-shop by the aHours shader) ──
// ?winmap swaps in the parallax interior-mapping glass (v2); default is the v1 window material.
if (windowGeos.length) {
const merged = mergeGeometries(windowGeos, false);
windowGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, WINMAP ? interiorWindowMaterial() : windowMaterial()); // shared → NOT disposed
group.add(mesh);
disposables.push(merged);
}
// ── CLOSED plates (merged; the shared shader shows each only while its shop is shut) ──
if (plateGeos.length) {
const merged = mergeGeometries(plateGeos, false);
plateGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, closedPlateMaterial()); // shared → NOT disposed per chunk
mesh.renderOrder = 2;
group.add(mesh);
disposables.push(merged);
}
// ── signs (merged, one atlas texture) ──
if (signQuads.length) {
const merged = mergeGeometries(signQuads.map((s) => s.geo || s), false);
signQuads.forEach((s) => (s.geo || s).dispose());
const tex = new THREE.CanvasTexture(atlasCanvas);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // ⅓ less GPU mem, NPOT-safe
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
const mesh = new THREE.Mesh(merged, mat);
disposables.push(merged, tex, mat);
group.add(mesh);
}
// Windows + plates are now driven by the shared shop-hours uniforms (setFacadeHours on a segment
// change), so night no longer needs a per-chunk window toggle. Kept for the ChunkManager's call.
function applyNight() { /* no-op: window/plate state is the shared uHour/uNight uniform */ }
function dispose() {
group.traverse((o) => {
if (o.isInstancedMesh) o.dispose && o.dispose(); // frees instanceMatrix/instanceColor
if (o.isMesh || o.isInstancedMesh) o.geometry && o.geometry.dispose && o.geometry.dispose();
});
for (const d of disposables) d.dispose && d.dispose();
group.clear();
}
return { group, colliders, doorMesh, doorRects, applyNight, dispose };
}