PROCITY/web/js/world/furniture.js
m3ultra 6323d58736 Lane B round-4: closed facades (§3.5) + shot poses/letterbox + furniture GLB + novelty_record
- B1: per-shop closed-facade visuals — dark windows + red CLOSED plate + door tooltip, batched via a
  per-vertex aHours attr + two shared shaders + one uHour/uNight uniform pair on a procity:segment
  event (facade atlas untouched, no per-shop materials, no per-frame poll). 22:00 → only the openLate
  video shop lit, all others CLOSED; ~200 draws worst-view with citizens.
- B2: the shots 'letterbox' was camera-jammed-into-a-wall poses, not a composer bug (composer proven
  correctly sized headless). Reframed street_noon/arcade/warehouse to look down a street
  (streetViewPose), added the 3 missing bookmarks, night_neon now frames the lit video shop amid
  CLOSED neighbours, + a defensive composer resize. shots.py: 10 un-letterboxed shots, 0 errors.
- B3: furniture GLB use-if-ready upgrade (bench + food_cart, sit on their footprints) + novelty_record
  placed at record stores. novelty_record GLB is 26.5k tris (5x glb_law) → stays low-poly primitive
  until Lane E decimates it; ?noassets verified 0 network.

qa.sh --strict GREEN (4/4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:59:27 +10:00

268 lines
13 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';
const FOOT = 3.5;
// ── 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 (≤5k tris/glb_law) are enabled — a heavy prop × many instances blows the ≤200k
// tri gate. Measured: bench 1.9k ✓, food_cart 5.0k ✓, streetlight 5.0k ✓, 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). Placement (record-store adjacency) is unchanged.
const GLB_FILES = {
bench: 'procity_street_bench_01.glb',
food_cart: 'procity_street_food_cart_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 }),
};
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 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 + bins 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));
const nx = bx + ux * 1.4, nz = bz + uz * 1.4;
if (here(nx, nz)) pushYaw(lists.bin, nx, 0, nz, 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);
}
}
}
// one bus stop near each main-street edge's near end
if (e.kind === 'main') {
const s = 10, off = (halfRoad + FOOT - 0.9);
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.busstop, x, 0, z, yaw + Math.PI);
}
}
// ── 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(t.bin, mat.bin, lists.bin);
emit(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 };
}
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