PROCITY/web/js/world/washing.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

199 lines
11 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 — 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.10.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; } };
}