// PROCITY Lane B — washing.js (v8 WAVE 1 §1.4 — the washing on the Hills Hoist) // // The rest of §1.4 (fence, hoist, aerial, tank) lives inside `buildings.js`'s existing `boxMats` // InstancedMesh and costs **+0 draws**. The washing cannot: a sheet is a flat quad with a fabric // map, `boxMats` is a BoxGeometry sharing ONE untextured shell material with every building in the // chunk, and per-instance textures do not exist. So the washing needs a mesh of its own — and this // is where the brief's shape and the budget law disagree, loudly: // // • A per-chunk washing mesh is "+1 draw" the way a per-chunk anything is: **+1 × up to 31 live // chunks at the dispose window = up to +31 draws** against a pinned margin of **8**. That is not // a rounding error, it is 4× the entire remaining budget. // • **ONE TOWN-WIDE `InstancedMesh` is 1 draw at any N** (v8 law, charter §Laws). So the washing // is town-wide, capped at the settled shared cap of **256**, spent **proximity-first**, and // refilled off `chunks.onChunkBuilt` / `onChunkDisposed` — the seam R37 §0.4 wired precisely so // a v8 standing layer would not have to invent its own. // // **FREE WHEN EMPTY, and to the vendored line rather than "almost".** `three.module.js:2193-2199` // returns before the GL call when an InstancedMesh's count is 0, and `render.calls++` lives // downstream at `:4094`. So: all 21 real towns (zero house/yard lots — 100% `use:'shop'`, charter // ruling 2) pay **exactly 0**, and so does any night, because the washing comes in after dark. // // **THE SWAY IS THE WHOLE POINT AND THE SHIPPED WEIGHT WAS UPSIDE DOWN.** `wind.js`'s v3.2 weight is // `wH = clamp(y/4.4,0,1); wH *= wH` — top-weighted, base planted, correct for a gum. A sheet is the // other way round: pegged at the top, free at the hem. Under the canopy weight a 1.6 m line of // washing moves 0.1–0.7 cm AT THE HEM (and most at the pegs, which is backwards). This layer runs // `wind.js`'s new `pegged` mode instead — hem weight 1, peg weight 0 — measured at **4.5 cm calm / // 31 cm gusty**, i.e. ~52× the shipped weight and in the right place. Amplitude still rides the ONE // shared `uWindAmp`, so at dead calm (?weather=0, ?classic=1) the displacement is exactly `* 0.0`. // // PROCEDURAL-FIRST: the fabric is a canvas weave (no fetch, `?noassets`-clean by construction). When // Lane E's asset wave lands a garment atlas, this becomes a `map` swap + a per-instance UV rect — // still ONE mesh, still ONE draw. import * as THREE from 'three'; import { rng, frange } from '../core/prng.js'; import { hoistAnchors } from './buildings.js'; import { applyWind } from './wind.js'; // The shared standing-layer instance cap settled in R37 §0.3. One number, written down once. export const WASHING_CAP = 256; const PEG_DROP = 1.0; // local height of the quad: pegs at y=1, hem at y=0 (the `pegged` topY) // 90s Australian laundry, not a fashion range: sheets, towels, school uniforms, tea towels. const GARMENTS = [ { w: 1.15, h: 1.25, tints: ['#e8e4dc', '#dfe4ea', '#e6ded0'] }, // sheet / pillowcase { w: 0.62, h: 0.95, tints: ['#c8683f', '#3f6f8c', '#7a9a52', '#c8b04a'] }, // towel { w: 0.54, h: 0.78, tints: ['#e2e2e6', '#8ea8c4', '#d8d2c0'] }, // shirt { w: 0.42, h: 0.48, tints: ['#dcd6c6', '#b8c8b0', '#e0cfc0'] }, // tea towel / smalls ]; // A plain woven-cloth texture: warp/weft noise + a soft vertical shadow so a hanging quad reads as // cloth rather than as a coloured card. Canvas only — no fetch, no manifest, no ?noassets branch. let _tex = null; function fabricTexture() { if (_tex) return _tex; const c = document.createElement('canvas'); c.width = c.height = 64; const x = c.getContext('2d'); x.fillStyle = '#ffffff'; x.fillRect(0, 0, 64, 64); let s = 0x9e3779b9; const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); for (let i = 0; i < 64; i++) { // weave x.fillStyle = `rgba(0,0,0,${0.03 + rnd() * 0.05})`; x.fillRect(0, i, 64, 1); x.fillStyle = `rgba(255,255,255,${0.04 + rnd() * 0.05})`; x.fillRect(i, 0, 1, 64); } const g = x.createLinearGradient(0, 0, 0, 64); // hangs darker at the fold, lighter at the hem g.addColorStop(0, 'rgba(0,0,0,0.20)'); g.addColorStop(0.35, 'rgba(0,0,0,0.04)'); g.addColorStop(1, 'rgba(255,255,255,0.06)'); x.fillStyle = g; x.fillRect(0, 0, 64, 64); _tex = new THREE.CanvasTexture(c); _tex.colorSpace = THREE.SRGBColorSpace; _tex.generateMipmaps = false; _tex.minFilter = THREE.LinearFilter; return _tex; } let _mat = null; function washingMaterial() { if (_mat) return _mat; _mat = new THREE.MeshStandardMaterial({ map: fabricTexture(), roughness: 0.94, metalness: 0, side: THREE.DoubleSide }); applyWind(_mat, { topY: PEG_DROP, mode: 'pegged' }); // R38: the INVERTED weight (see header) return _mat; } /** * createWashing({ scene, plan, citySeed, chunks, camera, lighting }) → { group, update, dispose, state } * The shell constructs this only on a non-classic boot with ?washing≠0. Constructed on a town with * no hoists it allocates nothing and never adds a mesh — so every real town is provably free. */ export function createWashing({ scene, plan, citySeed, chunks, camera, lighting }) { const group = new THREE.Group(); group.name = 'washing'; const state = { hoists: 0, items: 0, active: 0, cap: WASHING_CAP, rebuilds: 0, night: false }; // ── the item table: derived ONCE from buildings.js's own yardLayout, never re-invented ── // hoistAnchors() is a pure function of (plan, citySeed) that reads the SAME `yardLayout` the // geometry is built from, so a peg can never end up where no arm is. const anchors = hoistAnchors(plan, citySeed >>> 0); state.hoists = anchors.length; const items = []; for (const a of anchors) { const r = rng(a.seed, 'washing', 0); // ~90% of hoists have something on them; a few are bare, because some are. const n = r() < 0.10 ? 0 : 1 + Math.floor(r() * 4); for (let i = 0; i < n; i++) { const g = GARMENTS[Math.floor(r() * GARMENTS.length)]; const armIdx = Math.floor(r() * 4); const armYaw = a.yaw + armIdx * (Math.PI / 2); const rad = frange(r, 0.35, Math.max(0.4, a.arm * 0.92)); const sx = frange(r, 0.85, 1.18), sy = frange(r, 0.82, 1.15); items.push({ x: a.x + Math.cos(armYaw) * rad, z: a.z + Math.sin(armYaw) * rad, y: a.y - 0.06, // the pegs sit just under the arm yaw: armYaw + Math.PI / 2, // the sheet hangs across the arm sx: g.w * sx, sy: g.h * sy, color: new THREE.Color(g.tints[Math.floor(r() * g.tints.length)]), }); } } state.items = items.length; let mesh = null, geo = null; const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(); const _up = new THREE.Vector3(0, 1, 0); if (items.length) { // unit quad, pegs at y = PEG_DROP, hem at y = 0 — which is what the `pegged` wind weight expects geo = new THREE.PlaneGeometry(1, PEG_DROP).translate(0, PEG_DROP / 2, 0); mesh = new THREE.InstancedMesh(geo, washingMaterial(), Math.min(items.length, WASHING_CAP)); mesh.name = 'washing-layer'; mesh.castShadow = false; mesh.receiveShadow = false; mesh.frustumCulled = false; // one town-wide mesh: its bounds are the whole town, so culling it // whole is never right and computing them each refill is wasted work mesh.count = 0; // nothing until the first refill ⇒ boots at 0 draws group.add(mesh); scene.add(group); } // ── proximity-first spending ──────────────────────────────────────────────────────────────── // The cap is spent on the NEAREST items, so a 188 km-of-road town spends its 256 where the player // is rather than on a suburb 6 km away. Refills are event-driven (the R37 §0.4 chunk seam) plus a // movement guard, never per-frame: an InstancedMesh matrix rewrite is cheap but not free. let dirty = true, lastX = 1e9, lastZ = 1e9; const markDirty = () => { dirty = true; }; const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {}; const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {}; const order = items.map((_, i) => i); function refill(px, pz) { if (!mesh) return; // The washing comes in at night — and an empty count is a REAL zero (the vendored early-return), // so the whole layer costs nothing between dusk and dawn. const night = !!(lighting && lighting.isNight && lighting.isNight()); state.night = night; if (night) { mesh.count = 0; state.active = 0; return; } order.sort((a, b) => { const ia = items[a], ib = items[b]; return ((ia.x - px) ** 2 + (ia.z - pz) ** 2) - ((ib.x - px) ** 2 + (ib.z - pz) ** 2); }); const n = Math.min(order.length, WASHING_CAP); for (let i = 0; i < n; i++) { const it = items[order[i]]; // THE PEGS GO ON THE LINE, and the drop is per-instance. The unit quad spans local y 0..1 with // the pegs at 1, so the instance origin must sit `sy` below the arm — NOT `PEG_DROP` below it. // The first cut used the unscaled constant, which hung every garment with its pegs at // `arm − 1 + sy`: a tea towel floated 0.5 m under the line and a whole street of washing was // *invisible behind its own 1.75 m back fence* from any angle that could see the yard. The // instance count, the draw count and the cap were all green while nothing could be seen. _p.set(it.x, it.y - it.sy, it.z); _q.setFromAxisAngle(_up, it.yaw); _s.set(it.sx, it.sy, 1); mesh.setMatrixAt(i, _m.compose(_p, _q, _s)); if (mesh.instanceColor !== undefined) mesh.setColorAt(i, it.color); } mesh.count = n; mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; state.active = n; state.rebuilds++; } function update() { if (!mesh) return; const p = camera.position; const moved = (p.x - lastX) ** 2 + (p.z - lastZ) ** 2; const nightNow = !!(lighting && lighting.isNight && lighting.isNight()); if (!dirty && moved < 24 * 24 && nightNow === state.night) return; dirty = false; lastX = p.x; lastZ = p.z; refill(p.x, p.z); } function dispose() { offBuilt(); offGone(); if (mesh) { mesh.dispose && mesh.dispose(); group.remove(mesh); } if (geo) geo.dispose(); scene.remove(group); // the material + fabric texture are module-shared (one town per session) — not disposed here } return { group, update, dispose, state, get count() { return mesh ? mesh.count : 0; } }; }