PROCITY/web/js/interiors/stock.js
m3ultra 0e9d3fb0f2 Lane C: on-demand seeded shop interiors (library + test page)
Every shop door opens into a unique, believable, seeded interior — generated
in ~4ms, byte-identical every revisit (shop.seed), themed by shop.type.

- interiors.js: buildInterior(shop, THREE, opts) public API — pure fn of shop,
  returns {group, spawn, exits, places, dims, placement, dispose()}.
- theme.js: 9 CITY_SPEC type recipes (archetype/wallpaper/floor bias, clutter,
  counter pos, fittings mix, stock kind, signage) + type aliasing + one-time
  mergeRegistry() override seam for Lane F. Standalone (no hard registry dep).
- shell.js: room shell from lot x archetype (cosy/gallery/wide/hall/pokey),
  glazed shopfront + street backdrop, blocked back doorway, interior lighting.
- fittings.js: parametric kit ported from 90sDJsim + extended (bins, crates,
  4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner
  racks, armchair, escalator, pegboard, barred window, returns slot, art).
- layout.js: per-archetype zones, thriftgod shuffled wall-slot system,
  occupancy grid, guaranteed door->counter flood-fill path (pull/carve).
- stock.js: v1 visual stock (pooled canvas sleeves/spines/boxes/garments/snacks
  with price stickers) + stockAdapter hook for BaseGod content later.
- context.js: seed sub-streams + shared-geometry cache + leak-free disposeAll().
- glb.js: optional GLB hero-prop upgrade via Lane E manifest (off by default,
  primitive fallback).
- interior_test.html: standalone page — seed/type/archetype, first-person walk,
  wireframe/occupancy/path debug, 50-room soak (perf + leak + determinism).

Acceptance (verified): same seed -> identical placement (0/810 mismatches);
9 types x 5 archetypes render sensibly (docs/shots/laneC grid); build <50ms
(steady ~4ms, soak worst 8ms); leak-free dispose (geo/tex delta 0); door->counter
path always exists (0 fails, 0 carves); runs with zero assets and zero network.
Adversarial multi-agent review: 5 findings, all fixed and re-verified.

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

214 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;
}
}
// 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;
}
return;
}
if (kind === 'spine') { // books / VHS: thin spines edge-out
const th = Math.min(0.03, run / (n + 2));
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
}
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;
}
return;
}
// default 'box' / 'snack': upright cartons in a row, faces forward (+Z)
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.7);
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
const side = ctx.mat(pick(r, kind === 'snack' ? SNACK : CARD), 0.85);
const g = ctx.boxGeo(bw * 0.88, bh, bd); // shared geometry
const mesh = new ctx.THREE.Mesh(g, [side, side, side, side, m, side]); // face (+Z) wears the cover
mesh.position.set(slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01);
parent.add(mesh); // face `m` + `side` already tracked via ctx.mat; geometry via ctx.geom
}
}
// 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;
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;
}
}