PROCITY/web/js/world/furniture.js
m3ultra ee16545941 Lane B round 17 (v3.2): wind sway SHIPS -- weather-driven gum-tree sway (ledger #5, parked since R7)
Verdict: shipped, not killed. The R7 park reason ("touches shared materials") is a non-blocker under
R16's measurement-over-brief standard: the gum-tree billboard material is B-owned (furniture.js), so
this is entirely Lane B -- no C/E material seam. Verified fresh Chromium seed 20261990.

- New web/js/world/wind.js: shared uniforms (uWindTime/uWindAmp) + applyWind(material) (onBeforeCompile
  vertex patch: top-weighted horizontal sway, per-instance world-position phase, USE_INSTANCING-guarded)
  + updateWind(dt,intensity). furniture.js applies it to the tree material; weather.js drives it from
  update(dt) (base breeze 0.05 clear -> 0.05+intensity*0.30 ~0.35 rain gusts).
- Trees only. Awnings excluded by MEASUREMENT: rigid posted-verandah box slabs don't flutter (fabric
  canopy ripple = separate v3.3, shares the rigid awning InstancedMesh today).
- Byte-identical when weather off (the covenant): updateWind runs only inside weather.update, and the
  weather system isn't constructed under ?weather=0 / ?classic=1 -> uWindAmp stays 0 -> sin(...)*0.0==0.0
  -> identical vertices. Proven: amp=0 two times = 0/180000 px differ; ?weather=0 boot = 0 tree-motion px;
  ?classic=1 = amp 0, trees render, 0 errors. No plan/fetch/rng change -> F's classic gate untouched.
- Sway works: rain amp 0.305 moves ~14100/180000 region px over 0.8s (exaggerated 2.5 -> 101k); trees
  render clean, no glitch. ~0 draws (ALU ops in the existing tree InstancedMesh vertex program), 0 errors.

-> Lane F (B->F handshake): SWAY SHIPPED. In old frames under the default boot; ?classic=1/?weather=0
   sway-free + byte-identical. Procedural (no asset -> E has nothing to do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:30:09 +10:00

305 lines
16 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 — furniture.js
// Instanced street furniture along the footpaths: streetlights (emissive lamp at night), benches,
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
//
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs and
// runs fully under ?noassets. Mapped types (bench + the novelty_record / food_cart landmark props)
// upgrade to depot GLBs via a SYNCHRONOUS "use-if-ready" check at chunk-build time (no async write
// into a live chunk): a chunk built before the GLB lands uses its primitive; later chunks use the GLB.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { loadGLB } from '../core/loaders.js';
import { rng, frange } from '../core/prng.js';
import { chunkOf, resolveEdges } from './planutil.js';
import { applyWind } from './wind.js'; // weather-driven vertex sway on the gum-tree billboards (R17)
const FOOT = 3.5;
// bus-shelter footprint (from manifest: 2.33 × 1.41 m) + a facing tweak applied to the along-edge
// yaw so the open front reads toward the road (verified in-browser; π flips it if the GLB is mirrored).
const SHELTER_W = 2.33, SHELTER_D = 1.41, SHELTER_YAW = 0;
// ── 3GOD-depot furniture GLB upgrade (fail-soft; ?noassets → primitives only, zero network) ──────
// Each mapped type loads ONCE at module init. buildChunkFurniture checks `_glbReady[type]`
// SYNCHRONOUSLY at build time — so a chunk built before the GLB lands uses its primitive, and every
// chunk built after uses the GLB (streaming rebuilds pick it up). No async write into a live chunk.
const NOASSETS = (() => { try { const p = new URLSearchParams(location.search); return p.has('noassets') && p.get('noassets') !== '0'; } catch { return false; } })();
// Only LIGHT GLBs (≤~8k tris) are enabled — a heavy prop × many instances blows the ≤200k tri gate.
// Measured: bench 1.9k ✓, food_cart 5.0k ✓, bin 3.0k ✓, bus_shelter 8.0k ✓ (only 13 per town), but
// novelty_record is **26.5k tris** (5× over) → it stays on the low-poly PRIMITIVE until Lane E
// decimates it (same treatment as the ped rigs — see LANE_B_NOTES).
const GLB_FILES = {
bench: 'procity_street_bench_01.glb',
food_cart: 'procity_street_food_cart_01.glb',
bin: 'procity_street_bin_01.glb',
bus_shelter: 'procity_street_bus_shelter_01.glb',
};
const _glbReady = {}; // type → { geo, mat } once (and if) the GLB loads and merges to one mesh
// Merge a GLB's meshes into ONE geometry (+ its first material) so the prop can be InstancedMesh'd.
// Multi-material GLBs that won't merge return null → the primitive stays (fail-soft).
function extractGLB(gltf) {
const geos = []; let material = null;
gltf.scene.updateWorldMatrix(true, true);
gltf.scene.traverse((o) => {
if (!o.isMesh || !o.geometry) return;
const g = o.geometry.clone(); g.applyMatrix4(o.matrixWorld);
for (const name of Object.keys(g.attributes)) if (name !== 'position' && name !== 'normal' && name !== 'uv') g.deleteAttribute(name);
g.morphAttributes = {};
if (!g.attributes.normal) g.computeVertexNormals();
if (!g.attributes.uv) g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2));
geos.push(g);
if (!material) material = Array.isArray(o.material) ? o.material[0] : o.material;
});
if (!geos.length) return null;
let merged = null;
try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
geos.forEach((g) => g.dispose());
if (!merged) return null; // heterogeneous → keep the primitive
return { geo: merged, mat: material || new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 }) };
}
if (!NOASSETS) {
for (const [key, file] of Object.entries(GLB_FILES)) {
loadGLB('depot:' + file).then((g) => { if (g) { const ex = extractGLB(g); if (ex) _glbReady[key] = ex; } }).catch(() => {});
}
}
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
let _tpl = null;
function templates() {
if (_tpl) return _tpl;
const box = (w, h, d, x = 0, y = 0, z = 0) => {
const g = new THREE.BoxGeometry(w, h, d); g.translate(x, y, z); return g;
};
// streetlight pole + arm (dark). Lamp head is separate (emissive).
const pole = mergeGeometries([
box(0.12, 4.2, 0.12, 0, 2.1, 0),
box(0.09, 0.09, 1.1, 0, 4.1, 0.55),
], false);
const lamp = box(0.34, 0.18, 0.34, 0, 4.05, 1.05);
// bench: seat + back + two legs
const bench = mergeGeometries([
box(1.6, 0.08, 0.45, 0, 0.45, 0),
box(1.6, 0.5, 0.06, 0, 0.72, -0.2),
box(0.08, 0.45, 0.4, -0.7, 0.22, 0),
box(0.08, 0.45, 0.4, 0.7, 0.22, 0),
], false);
// bin: short cylinder
const bin = new THREE.CylinderGeometry(0.28, 0.24, 0.9, 10); bin.translate(0, 0.45, 0);
// bus stop shelter: two posts + flat roof + a small bench
const busstop = mergeGeometries([
box(0.1, 2.3, 0.1, -1.3, 1.15, -0.5), box(0.1, 2.3, 0.1, 1.3, 1.15, -0.5),
box(3.0, 0.12, 1.4, 0, 2.35, 0),
box(2.4, 0.08, 0.4, 0, 0.5, -0.4),
], false);
// gum tree: crossed alpha-textured planes
const treeGeo = mergeGeometries([
new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
], false);
// novelty record (record-store landmark): a giant vinyl disc facing +Z on a short post
const novelty = mergeGeometries([
box(0.14, 1.3, 0.14, 0, 0.65, 0), // post
new THREE.CylinderGeometry(0.85, 0.85, 0.07, 22).rotateX(Math.PI / 2).translate(0, 1.65, 0), // the record
new THREE.CylinderGeometry(0.16, 0.16, 0.09, 14).rotateX(Math.PI / 2).translate(0, 1.65, 0.01), // label
], false);
// food cart: body + canopy + two posts (primitive fallback for the market food_cart GLB)
const foodcart = mergeGeometries([
box(1.5, 0.9, 0.95, 0, 0.65, 0),
box(1.7, 0.1, 1.15, 0, 1.55, 0),
box(0.07, 0.65, 0.07, -0.75, 1.2, 0.5), box(0.07, 0.65, 0.07, 0.75, 1.2, 0.5),
], false);
_tpl = { pole, lamp, bench, bin, busstop, treeGeo, novelty, foodcart };
return _tpl;
}
// tree billboard texture (canvas — no font files, house law)
let _treeTex = null;
function treeTexture() {
if (_treeTex) return _treeTex;
const c = document.createElement('canvas'); c.width = c.height = 128;
const x = c.getContext('2d');
x.clearRect(0, 0, 128, 128);
x.fillStyle = '#6a5a44'; x.fillRect(58, 74, 12, 54); // trunk
const blobs = [[64, 44, 34], [44, 56, 24], [84, 56, 24], [64, 30, 22]];
for (const [bx, by, br] of blobs) {
x.fillStyle = ['#5c7a3e', '#6b8a48', '#4f6d38'][(bx + by) % 3];
x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
}
_treeTex = new THREE.CanvasTexture(c); _treeTex.colorSpace = THREE.SRGBColorSpace;
return _treeTex;
}
// ── shared materials (module-level; not disposed per chunk) ──
let _mats = null;
function materials() {
if (_mats) return _mats;
_mats = {
dark: new THREE.MeshStandardMaterial({ color: 0x33322f, roughness: 0.7, metalness: 0.2 }),
lamp: new THREE.MeshStandardMaterial({ color: 0x2a2a26, roughness: 0.5, emissive: new THREE.Color(0xffd9a0), emissiveIntensity: 0 }),
wood: new THREE.MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.8 }),
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl
foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }),
};
applyWind(_mats.tree, { topY: 4.4 }); // gum-tree canopy sways when weather is on (amp 0 ⇒ byte-identical off)
return _mats;
}
const _m = new THREE.Matrix4();
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3(1, 1, 1);
const _up = new THREE.Vector3(0, 1, 0);
/**
* buildChunkFurniture(chunkData, ctx, cx, cz) → { group, applyNight, dispose }
* ctx: { plan, citySeed, night }
* Places furniture along every street edge, keeping only items whose position lands in this chunk.
*/
export function buildChunkFurniture(chunkData, ctx, cx, cz) {
const group = new THREE.Group();
group.name = 'chunk-furniture';
const t = templates(), mat = materials();
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [], novelty: [], foodcart: [] };
const colliders = []; // oriented-rect keep-outs (bus shelters) → player collision (via chunks.js)
const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
_p.set(x, y, z); _q.setFromAxisAngle(_up, yaw); _s.set(sx, sy, sz);
list.push(_m.clone().compose(_p, _q, _s));
};
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len; // along
const px = -uz, pz = ux; // perp (unit)
const yaw = Math.atan2(ux, uz); // face along +Z→dir
const r = rng(ctx.citySeed, 'furn', hashEdge(e.id));
const halfRoad = e.width / 2;
// streetlights every ~18m, alternating sides, near the kerb
let side = 1;
for (let s = 8; s < len - 4; s += 18) {
const off = (halfRoad + 0.7) * side;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) {
const armYaw = side > 0 ? yaw + Math.PI : yaw; // arm reaches over the road
pushYaw(lists.pole, x, 0, z, armYaw);
pushYaw(lists.lamp, x, 0, z, armYaw);
}
side = -side;
}
// benches every ~26m on the building side of the footpath, facing the road
for (let s = 14; s < len - 6; s += 26) {
const bside = (Math.floor(s / 26) % 2) ? 1 : -1;
const off = (halfRoad + FOOT - 0.8) * bside;
const bx = e.ax + ux * s + px * off, bz = e.az + uz * s + pz * off;
if (here(bx, bz)) pushYaw(lists.bench, bx, 0, bz, yaw + (bside > 0 ? Math.PI : 0));
}
// bins: genuinely sparse (~1 per block), seeded, one side of the footpath — a country town, not a
// mall. Wide cadence also bounds the 3k-tri bin GLB's contribution to the ≤200k tri gate.
const binH = hashEdge(e.id);
for (let s = 30; s < len - 8; s += 78) {
const bside = ((binH + Math.floor(s / 78)) % 2) ? 1 : -1;
const off = (halfRoad + FOOT - 0.6) * bside;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.bin, x, 0, z, 0);
}
// gum trees only along side/lane verges (main street has no verge room)
if (e.kind === 'side' || e.kind === 'lane') {
for (let s = 6; s < len - 4; s += 22) {
for (const vs of [1, -1]) {
const off = (halfRoad + FOOT + 1.2) * vs;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.tree, x, 0, z, frange(r, 0, Math.PI), 1, 1 + r() * 0.3, 1);
}
}
}
// Bus shelters — 13 per town, ON THE STREET GRAPH (they are the future tram/bus stops).
// Deterministic rule (see busShelterStops() + LANE_B_NOTES): at the MIDPOINT of every MAIN edge
// whose id hashes to 0 mod 3, on the road-side footpath near the kerb, long axis (2.33 m) ALONG
// the edge, open front to the road. A solid collider so the player rounds it (collision-verified).
if (e.kind === 'main' && hashEdge(e.id) % 3 === 0) {
const off = halfRoad + 1.7; // on the footpath, hard by the kerb
const sx = e.ax + ux * (len * 0.5) + px * off, sz = e.az + uz * (len * 0.5) + pz * off;
const syaw = Math.atan2(-uz, ux) + SHELTER_YAW; // long axis along the edge (+ facing tweak)
if (here(sx, sz)) {
pushYaw(lists.busstop, sx, 0, sz, syaw);
colliders.push({ x: sx, z: sz, ry: syaw, hw: SHELTER_W / 2, hd: SHELTER_D / 2, use: 'shelter' });
}
}
}
// ── landmark props keyed to a shop (in front of it on the footpath) ──
// Placed with their SHOP's chunk (the shop is in this chunk), so no here() gate → never dropped.
const lotById = new Map(chunkData.lots.map((l) => [l.id, l]));
for (const shop of (chunkData.shops || [])) {
const lot = lotById.get(shop.lot); if (!lot) continue;
const ry = lot.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); // front normal
const rx = Math.cos(ry), rz = -Math.sin(ry); // right along the frontage
const half = (lot.d || 8) / 2;
if (shop.type === 'record') { // novelty record outside the record store
const fx = lot.x + nx * (half + 1.3) + rx * 1.8, fz = lot.z + nz * (half + 1.3) + rz * 1.8;
pushYaw(lists.novelty, fx, 0, fz, ry); // record face toward the street
} else if (shop.type === 'stall' || shop.type === 'milkbar') { // food cart by the market / milk bar
const fx = lot.x + nx * (half + 1.6), fz = lot.z + nz * (half + 1.6);
pushYaw(lists.foodcart, fx, 0, fz, ry);
}
}
const ims = [];
const emit = (geo, material, list, cast = true) => {
if (!list.length) return;
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) im.setMatrixAt(i, list[i]);
im.instanceMatrix.needsUpdate = true;
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
group.add(im); ims.push(im);
};
// GLB-if-ready: use the depot GLB geometry/material once it's loaded, else the primitive fallback.
const glb = (key, geo, material) => (_glbReady[key] ? [_glbReady[key].geo, _glbReady[key].mat] : [geo, material]);
emit(t.pole, mat.dark, lists.pole);
emit(t.lamp, mat.lamp, lists.lamp, false);
emit(...glb('bench', t.bench, mat.wood), lists.bench);
emit(...glb('bin', t.bin, mat.bin), lists.bin);
emit(...glb('bus_shelter', t.busstop, mat.shelter), lists.busstop);
emit(t.treeGeo, mat.tree, lists.tree, false);
emit(...glb('novelty_record', t.novelty, mat.novelty), lists.novelty);
emit(...glb('food_cart', t.foodcart, mat.foodcart), lists.foodcart);
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
applyNight(!!ctx.night);
function dispose() {
for (const im of ims) { im.dispose && im.dispose(); group.remove(im); }
// shared templates/materials intentionally NOT disposed (reused by other chunks)
group.clear();
}
return { group, applyNight, dispose, colliders };
}
function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
// The town's bus/tram stops, derived from the SAME deterministic rule buildChunkFurniture places the
// shelters by, so the future vehicle loop can enumerate stops without touching furniture internals.
// Rule: midpoint of every MAIN edge whose id hashes to 0 mod 3, on the road-side footpath (kerb).
// Returns [{ edgeId, x, z, yaw }] in plan order. Zero side effects; pure function of the plan.
export function busShelterStops(plan) {
const stops = [];
for (const e of resolveEdges(plan)) {
if (e.kind !== 'main' || hashEdge(e.id) % 3 !== 0) continue;
const dx = e.bx - e.ax, dz = e.bz - e.az, len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len, px = -uz, pz = ux, off = e.width / 2 + 1.7;
stops.push({ edgeId: e.id, x: e.ax + ux * (len * 0.5) + px * off, z: e.az + uz * (len * 0.5) + pz * off, yaw: Math.atan2(-uz, ux) + SHELTER_YAW });
}
return stops;
}