// 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'; import { realSleeveMesh, buildBuyableShelf } from './stockpack.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). With ?stock=real the // adapter hands back a pack pool → every sleeve is a real cover PLANE sampling the shared atlas (batches // to one draw); otherwise procedural cardboard. 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 src = opts.adapter && opts.adapter(opts.shop, 'sleeve'); const pack = src && src.real ? src.pack : null; const pool = pack ? null : facePool(ctx, 'card', r); for (let i = 0; i < n; i++) { const z = -run / 2 + (i + 0.5) * step; let sl; if (pack) { sl = realSleeveMesh(ctx, pack, pick(r, pack.items), fw, h); // isStock + shared atlas material sl.position.set(slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z); parent.add(sl); } else { 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; } sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent); sl.userData.isStock = true; } sl.rotation.x = tilt + (r() - 0.5) * 0.05; sl.rotation.z = (r() - 0.5) * 0.04; } } // 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); // ?stock=real: book spines / toy boxes pull real covers from the pack (shared atlas material → batched). const src = !scatter && opts.adapter && opts.adapter(opts.shop, kind); const pack = src && src.real ? src.pack : null; 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; if (pack) { // real book spines → one buyable, addressable mesh const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw const pls = []; for (let i = 0; i < n; i++) { const bh = clear * (0.8 + r() * 0.2); const x = slot.x - run / 2 + (i + 0.5) * step; const rz = (slot.lean && i > n - 3) ? 0.18 : 0; pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: step * 0.86, h: bh, rz }); } const meshes = buildBuyableShelf(ctx, pack, pls); if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft } for (let i = 0; i < n; i++) { // procedural spines (no pack / merge failed) const bh = clear * (0.8 + r() * 0.2); const x = slot.x - run / 2 + (i + 0.5) * step; 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.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent); b.userData.isStock = true; if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over } 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; if (pack) { // real toy boxes → one buyable, addressable mesh const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw const pls = []; for (let i = 0; i < n; i++) { const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); const x = slot.x - run / 2 + (i + 0.5) * step; pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: bw * 0.88, h: bh }); } const meshes = buildBuyableShelf(ctx, pack, pls); if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft } for (let i = 0; i < n; i++) { // procedural cartons (no pack / merge failed) const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26); const x = slot.x - run / 2 + (i + 0.5) * step; 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, x, 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; } }