PROCITY/web/js/world/buildings.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it,
a magpie owns one street for six weeks of the year, and six point sources put a life off-screen.

MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`,
:8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre
spine steps at yaw 0, each frame fully streamed, through the shell's own composer:

  worst draws  224 -> 225   (+1)
  worst tris   89,502 -> 93,298   (+3,796 of ~75k spare)
  sum draws over 102 frames  16,676 -> 16,663   (-13)

The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with
them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time.
`?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw
/ +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws.

§1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION.
`townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key,
so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD
mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path.
The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome
on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`,
so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns.
`gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had;
making the slot addressable fixed it at the root, and both are selected now. E's three new grounds
landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for.

§1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a
yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a
hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands).

THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted
mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem
instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument
is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37
chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin.
Zero on every real town (no hoists ⇒ no mesh) and zero at night.

§1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the
road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m
from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never
mentions it. `swoopPos()` is exported and pure. The hat stays cut.

MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a
CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from
swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on
(citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days
out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%.

§1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff,
AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a
limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot;
3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability
control — one manifest key renamed away, served from a third port — makes that source report
`no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors.

TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256
instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height,
and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson
relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to
furniture.js's own streetlight rule.

LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive
is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The
`magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision
IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch.
furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000.

Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38.
0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets,
magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones.

Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
2026-08-03 18:53:26 +10:00

849 lines
45 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 { lotCollider } from './planutil.js';
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'];
// ── 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
})();
// 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 = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;
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);
// 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') {
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 + AWNING_DEPTH / 2, [])));
_s.set(w, 0.16, AWNING_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
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 };
}