Review debt: book room was 1,245 draws GLB-on. Diagnosed two drivers — multi-material stock boxes (one draw per face, 6x) and one mesh per stock item — then batched: - stock.js: stock boxes are single-material now; every stock mesh tagged userData.isStock. - batch.js (new): batchRoom() two-tier merge, called from interiors.js before the GLB upgrade. Room-level merge of all isStock across every fitting (grouped by material, world transforms baked) + per-fitting merge of fixture frames (kept in-group so record bins stay raycast targets for ?dig=1 and the GLB upgrade can hide the fixture). Uses vendored BufferGeometryUtils mergeGeometries. - glb.js: frame-hide is now by isStock tag (not frame-count), so batched fixtures hide while the room-level stock rides on the GLB. - interior_test.html: drawSweep() asserts <=350 draws across all type x archetype (GLB off and on), folded into the soak; exposed as window.PROCITY_C.drawSweep for F's harness (hook in LANE_C_NOTES). Result (drawSweep, seed 1990): GLB-off worst 152, GLB-on worst 313 (opshop/hall) — book 1,245 -> 189. Determinism 0 fails / 810, leak-free, dig still riffles batched bins, worst warm build 38ms (<50), visually identical. qa.sh --strict GREEN (5 gates). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
// PROCITY Lane C — visual stock (v1). Fills the fittings' `slots` with product silhouettes:
|
||
// canvas-texture cardboard sleeves / spines / boxes / snacks / magazines with price stickers
|
||
// (the dig.js sleeve trick — coloured cardboard + sticker). Real item data is the content phase;
|
||
// this stays purely cosmetic and byte-identical per seed.
|
||
//
|
||
// stock.fill(ctx, fitting, { shop, recipe, stockKind, adapter }, r)
|
||
// iterates fitting.slots and adds child meshes into fitting.group.
|
||
//
|
||
// stockAdapter hook (content phase): `adapter(shop, slotKind) => { texture } | { mesh } | null`.
|
||
// If it returns a texture, that texture skins the item face; a mesh replaces the item entirely;
|
||
// null (default) → procedural placeholder. BaseGod / GODVERSE data plugs in here without touching
|
||
// layout or fittings code.
|
||
//
|
||
// PERF + LEAK SAFETY: face textures are POOLED per room (a small seeded set reused across many
|
||
// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
|
||
|
||
import { pick } from './context.js';
|
||
|
||
// Muted 90s stock palette (cardboard, plastic, faded card).
|
||
const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
|
||
const SPINE = ['#7a2a2a', '#2a4a7a', '#2a6a4a', '#7a5a2a', '#5a2a6a', '#2a5a6a', '#6a6a2a', '#7a3a5a'];
|
||
const SNACK = ['#e04a3a', '#f0b428', '#3a9ae0', '#4ac04a', '#e04a9a', '#f07028', '#a04ae0'];
|
||
|
||
// ── per-room face-texture pool ──────────────────────────────────────────────────────
|
||
// A pooled canvas "face" for a stock kind: coloured card + a little price sticker + label bars.
|
||
function makeFace(ctx, kind, r) {
|
||
const cv = document.createElement('canvas');
|
||
cv.width = 64; cv.height = kind === 'spine' ? 128 : 64;
|
||
const x = cv.getContext('2d');
|
||
const pal = kind === 'spine' ? SPINE : kind === 'snack' ? SNACK : CARD;
|
||
const base = pick(r, pal);
|
||
x.fillStyle = base; x.fillRect(0, 0, cv.width, cv.height);
|
||
// faded border
|
||
x.strokeStyle = 'rgba(0,0,0,0.18)'; x.lineWidth = 3; x.strokeRect(2, 2, cv.width - 4, cv.height - 4);
|
||
if (kind === 'spine') {
|
||
// title bars down the spine
|
||
x.fillStyle = 'rgba(255,255,255,0.82)';
|
||
for (let i = 0; i < 3; i++) x.fillRect(10, 18 + i * 30 + (r() * 6 | 0), cv.width - 20, 8);
|
||
x.fillStyle = 'rgba(0,0,0,0.3)'; x.fillRect(8, cv.height - 20, cv.width - 16, 10);
|
||
} else {
|
||
// a cover blob + label lines
|
||
x.fillStyle = 'rgba(255,255,255,0.16)';
|
||
x.beginPath(); x.arc(32 + (r() - 0.5) * 16, 26, 12 + r() * 6, 0, 6.3); x.fill();
|
||
x.fillStyle = 'rgba(255,255,255,0.75)';
|
||
x.fillRect(8, 44, 34, 5); x.fillRect(8, 52, 22, 4);
|
||
}
|
||
// the op-shop price sticker — a fluoro dot with a $ scrawl
|
||
const sx = cv.width - 18, sy = 14;
|
||
x.fillStyle = '#f4e11a'; x.beginPath(); x.arc(sx, sy, 9, 0, 6.3); x.fill();
|
||
x.fillStyle = '#c1121f'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.fillText('$' + (1 + (r() * 9 | 0)), sx, sy + 1);
|
||
return ctx.canvasTexture(cv);
|
||
}
|
||
|
||
function facePool(ctx, kind, r, n = 6) {
|
||
const key = `_facePool_${kind}`;
|
||
if (!ctx[key]) { ctx[key] = []; for (let i = 0; i < n; i++) ctx[key].push(makeFace(ctx, kind, r)); }
|
||
return ctx[key];
|
||
}
|
||
|
||
// A garment silhouette (ported from 90sDJsim garmentCanvas), pooled per room.
|
||
const GARMENTS = ['tee', 'hoodie', 'jacket', 'dress', 'pants', 'shirt'];
|
||
function makeGarment(ctx, r) {
|
||
const shape = GARMENTS[(r() * GARMENTS.length) | 0];
|
||
const color = new ctx.THREE.Color().setHSL(r(), 0.45, 0.4 + r() * 0.2);
|
||
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 160;
|
||
const x = cv.getContext('2d');
|
||
const col = '#' + color.getHexString();
|
||
const dark = '#' + color.clone().offsetHSL(0, 0.05, -0.14).getHexString();
|
||
x.fillStyle = col; x.strokeStyle = dark; x.lineWidth = 4; x.lineJoin = 'round';
|
||
const P = pts => { x.beginPath(); x.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) x.lineTo(pts[i][0], pts[i][1]); x.closePath(); x.fill(); x.stroke(); };
|
||
if (shape === 'pants') P([[40, 8], [88, 8], [90, 152], [70, 152], [64, 62], [58, 152], [38, 152]]);
|
||
else if (shape === 'dress') { P([[48, 20], [80, 20], [104, 152], [24, 152]]); x.fillStyle = dark; x.beginPath(); x.ellipse(64, 22, 11, 6, 0, 0, 6.3); x.fill(); }
|
||
else {
|
||
P([[16, 46], [46, 26], [46, 60], [22, 78]]); P([[112, 46], [82, 26], [82, 60], [106, 78]]);
|
||
P([[42, 24], [86, 24], [86, 142], [42, 142]]);
|
||
x.fillStyle = dark; x.beginPath(); x.ellipse(64, 26, 12, 7, 0, 0, 6.3); x.fill();
|
||
if (shape === 'hoodie') { x.fillStyle = col; x.beginPath(); x.arc(64, 24, 15, Math.PI, 0); x.fill(); x.stroke(); }
|
||
}
|
||
x.fillStyle = 'rgba(255,255,255,0.22)'; x.fillRect(55, 62, 18, 20);
|
||
return ctx.canvasTexture(cv);
|
||
}
|
||
function garmentPool(ctx, r, n = 8) {
|
||
if (!ctx._garmentPool) { ctx._garmentPool = []; for (let i = 0; i < n; i++) ctx._garmentPool.push(makeGarment(ctx, r)); }
|
||
return ctx._garmentPool;
|
||
}
|
||
|
||
// ── fill entry point ─────────────────────────────────────────────────────────────────
|
||
export function fill(ctx, fitting, opts, r) {
|
||
for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r);
|
||
}
|
||
|
||
function fillSlot(ctx, parent, slot, opts, r) {
|
||
switch (slot.kind) {
|
||
case 'sleeve': return binFan(ctx, parent, slot, opts, r);
|
||
case 'garment': return hang(ctx, parent, slot, opts, r);
|
||
case 'treasure': return treasures(ctx, parent, slot, opts, r);
|
||
default: return shelfLine(ctx, parent, slot, opts, r); // box / spine / snack / magazine
|
||
}
|
||
}
|
||
|
||
// Ask the adapter for a face texture (content phase); null → pooled procedural face.
|
||
function faceTex(ctx, kind, opts, r) {
|
||
if (opts.adapter) {
|
||
const got = opts.adapter(opts.shop, kind);
|
||
if (got && got.texture) return got.texture;
|
||
}
|
||
return pick(r, facePool(ctx, kind === 'magazine' ? 'card' : kind, r));
|
||
}
|
||
|
||
// Packed leaning sleeves in a bin/crate; front few wear covers (dig.js look).
|
||
function binFan(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 12;
|
||
const fw = slot.faceW || 0.3, h = slot.height || 0.3, run = slot.run || 0.5, tilt = slot.tilt ?? -0.4;
|
||
const step = run / Math.max(1, n);
|
||
const pool = facePool(ctx, 'card', r);
|
||
for (let i = 0; i < n; i++) {
|
||
const z = -run / 2 + (i + 0.5) * step;
|
||
const front = i >= n - 4; // front sleeves show a cover
|
||
const faceMat = front ? ctx.mat('#ffffff', 0.7) : ctx.mat(pick(r, CARD), 0.85);
|
||
if (front) { faceMat.map = pick(r, pool); faceMat.color.set('#ffffff'); faceMat.needsUpdate = true; }
|
||
const sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent);
|
||
sl.rotation.x = tilt + (r() - 0.5) * 0.05;
|
||
sl.rotation.z = (r() - 0.5) * 0.04;
|
||
sl.userData.isStock = true;
|
||
}
|
||
}
|
||
|
||
// Neat row along local X. Handles box (upright), spine (edge-out), snack (small), magazine (leaning).
|
||
function shelfLine(ctx, parent, slot, opts, r) {
|
||
const kind = slot.kind;
|
||
const run = slot.run || 0.8, depth = slot.depth || 0.3, clear = slot.height || 0.3;
|
||
const scatter = slot.scatter;
|
||
const n = Math.max(1, slot.count || 4);
|
||
const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r);
|
||
|
||
if (scatter) { // tables / pegboards: random small goods
|
||
for (let i = 0; i < n; i++) {
|
||
const s = 0.09 + r() * 0.11;
|
||
const m = ctx.mat(pick(r, CARD), 0.85);
|
||
if (r() < 0.5) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; }
|
||
const b = ctx.box(s, slot.wall ? s * 1.4 : s, slot.wall ? 0.03 : s, m,
|
||
slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2),
|
||
slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent);
|
||
b.rotation.y = (r() - 0.5) * 0.6;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (kind === 'spine') { // books / VHS: thin spines edge-out
|
||
const step = (run - 0.04) / n;
|
||
for (let i = 0; i < n; i++) {
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const bh = clear * (0.8 + r() * 0.2);
|
||
const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent);
|
||
if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (kind === 'magazine') { // leaning mag faces
|
||
const step = run / n;
|
||
for (let i = 0; i < n; i++) {
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const b = ctx.box(step * 0.9, clear * 0.9, 0.01, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + clear * 0.45, slot.z, parent);
|
||
b.rotation.x = slot.tilt ?? -0.35;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// default 'box' / 'snack': upright cartons in a row, faces forward (+Z).
|
||
// SINGLE material (cover on all faces) so the whole run merges to one draw in batch.js — a
|
||
// multi-material box cost one draw PER face (6×), the biggest draw-count offender pre-round-6.
|
||
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
|
||
const step = run / n;
|
||
for (let i = 0; i < n; i++) {
|
||
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
|
||
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
|
||
const m = ctx.mat('#ffffff', 0.8);
|
||
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const mesh = ctx.box(bw * 0.88, bh, bd, m,
|
||
slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
|
||
mesh.userData.isStock = true;
|
||
}
|
||
}
|
||
|
||
// Garments hung along a rail.
|
||
function hang(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
|
||
const pool = garmentPool(ctx, r);
|
||
for (let i = 0; i < n; i++) {
|
||
const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1));
|
||
const m = ctx.mat('#ffffff', 0.92, { transparent: true, alphaTest: 0.4, side: ctx.THREE.DoubleSide });
|
||
m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const g = ctx.planeGeo(0.34, h);
|
||
const p = new ctx.THREE.Mesh(g, m);
|
||
p.position.set(gx, slot.y - 0.05 - h / 2, slot.z + (r() - 0.5) * 0.03);
|
||
p.rotation.y = (r() - 0.5) * 0.15;
|
||
p.userData.isStock = true;
|
||
parent.add(p);
|
||
}
|
||
}
|
||
|
||
// Small treasures on a glass-case shelf.
|
||
function treasures(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 4, run = slot.run || 1.0;
|
||
const cols = ['#c8a13a', '#b0b0b8', '#8a5a3a', '#3a6a8a', '#8a3a5a'];
|
||
for (let i = 0; i < n; i++) {
|
||
const s = 0.06 + r() * 0.06;
|
||
const b = ctx.box(s * (1 + r()), s, s, ctx.mat(pick(r, cols), 0.4, { metalness: 0.3 }),
|
||
slot.x - run / 2 + (run) * ((i + 0.5) / n), slot.y + s / 2, slot.z + (r() - 0.5) * (slot.depth || 0.2) * 0.5, parent);
|
||
b.rotation.y = r() * Math.PI;
|
||
b.userData.isStock = true;
|
||
}
|
||
}
|