// PROCITY Lane C — real record sleeves for `?stock=real` (round 7). Feeds Lane E's GODVERSE stock pack // (parody-transformed Discogs metadata + cover atlases) into the interior stock via the stockAdapter // seam built in v1. Read-only, no economy (buying stays a stub — that's a later round). // // Pack contract (agreed in LANE_C_NOTES): stock__index.json → // { version, atlas_px, cell, atlases:["…webp"], items:[{ id,title,artist,price,price_band,atlas,uv }] } // uv = [u0,v0,u1,v1] cover rect, ORIGIN TOP-LEFT (image-natural) — this module flips V for WebGL. // // Batching: every sleeve samples ONE shared atlas material with a per-item UV baked into its geometry, // so all real sleeves in a room merge (batch.js) to one draw per atlas — the ≤350 law holds. // Leak-free: the atlas texture + material are shared/cached in the pack (NOT per-room), like depot GLBs; // per-sleeve geometries are ctx-tracked and disposed with the room. // Fail-soft: index/atlas missing → getStockPack returns null → callers fall back to parody canvas. import * as THREE_NS from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; const _texLoader = new THREE_NS.TextureLoader(); // Where the TOWN-WIDE packs live. A per-shop (godverse) pack lives under its own base instead — // `assets/stock_godverse//` — and carries the SAME `stock__index.json` name, // because `type` is what picks the stock slot (SLOT_FOR: record→sleeve, book→spine, toy→box). export const DEFAULT_STOCK_BASE = 'assets/models/'; // CACHE IDENTITY IS (type, base) — never `type` alone. A per-shop atlas and the town-wide pack are // different packs that share a type; keying by type alone made `base` a no-op on a cache hit, so the // 2nd caller silently received the 1st caller's pack (R23, measured). That wasn't only a mix-up: it // defeated the fail-soft law — an UNSTOCKED shop of an already-cached type inherited that crate // instead of falling back to parody. (R24 ledger #1; the runtime is the contract's authority.) const packKey = (type, base) => `${base}|${type}`; const _packs = new Map(); // packKey → Promise const _resolved = new Map(); // packKey → pack|null (sync accessor for buildInterior) // Preload a pack (fetch index + atlas textures). Cached per (type, base). Call once before building // with ?stock=real — for a per-shop pack, pass that shop's base. export function preloadStockPack(type, { base = DEFAULT_STOCK_BASE, THREE = THREE_NS } = {}) { const key = packKey(type, base); if (_packs.has(key)) return _packs.get(key); const p = fetch(`${base}stock_${type}_index.json`) .then(r => (r.ok ? r.json() : null)) .then(idx => (idx && idx.items && idx.items.length ? buildPack(type, idx, base, THREE) : null)) .catch(() => null) .then(pack => { _resolved.set(key, pack); return pack; }); _packs.set(key, p); return p; } // Synchronous accessor — the resolved pack for (type, base), else null (buildInterior uses this). // `base` defaults to the town-wide packs, so every pre-v5 caller keeps its exact behaviour. export function getStockPack(type, base = DEFAULT_STOCK_BASE) { return _resolved.get(packKey(type, base)) || null; } async function buildPack(type, idx, base, THREE) { const names = idx.atlases || []; const materials = {}; await Promise.all(names.map(async (name) => { const url = /^depot:|^https?:/.test(name) ? name : base + name; let tex; try { tex = await _texLoader.loadAsync(url); } catch (e) { tex = null; } if (tex) { tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; } materials[name] = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.72, map: tex || null }); })); const first = names[0]; // Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a // single mesh (one draw), instead of fragmenting into one mesh per atlas the random picks happened to hit. const byAtlas = {}; for (const name of names) byAtlas[name] = []; for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it); const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length); return { type, items: idx.items, atlases: nonEmpty.length ? nonEmpty : names, itemsByAtlas: byAtlas, // shared material for an item's atlas (batching groups sleeves by this) material(item) { return materials[item.atlas] || materials[first]; }, // items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw) atlasPool(r) { const a = (this.atlases.length ? this.atlases : names); const name = a[(r() * a.length) | 0]; return byAtlas[name] && byAtlas[name].length ? byAtlas[name] : idx.items; }, // WebGL-flipped cover rect [u0, v0, u1, v1] (origin bottom-left) for a plane's UV uvRect(item) { const u = item.uv; return [u[0], 1 - u[3], u[2], 1 - u[1]]; }, }; } // Each pack type feeds exactly one stock slot kind so a record shop's pack never lands on a bric-a-brac // shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves). const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box' }; // The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers // (stock.binFan / stock.shelfLine / dig.js) pick a seeded subset. Other kinds → null → procedural. export function makeStockAdapter(pack) { if (!pack || !pack.items || !pack.items.length) return null; const want = SLOT_FOR[pack.type] || 'sleeve'; return (shop, slotKind) => (slotKind === want ? { real: true, pack } : null); } // Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so // batch.js merges it. Shared material (per atlas) → the whole bin batches to one draw. export function realSleeveMesh(ctx, pack, item, w, h) { const THREE = ctx.THREE; const g = ctx.geom(new THREE.PlaneGeometry(w, h)); const [u0, v0, u1, v1] = pack.uvRect(item); // already WebGL-flipped const a = g.attributes.uv; a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); // TL,TR,BL,BR a.needsUpdate = true; const mesh = new THREE.Mesh(g, pack.material(item)); mesh.userData.isStock = true; return mesh; } // One quad geometry for a placement, UV-baked to its atlas rect and transformed into fitting-local // space (so the merged mesh sits at identity). Shared shape with realSleeveMesh, minus the Mesh. function itemQuad(THREE, pack, pl) { const g = new THREE.PlaneGeometry(pl.w, pl.h); const [u0, v0, u1, v1] = pack.uvRect(pl.item); const a = g.attributes.uv; a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); if (pl.rz) g.rotateZ(pl.rz); if (pl.rx) g.rotateX(pl.rx); g.translate(pl.x, pl.y, pl.z); return g; } // BUY-ANYWHERE (round 9): build a shelf of real covers as merged, per-item-ADDRESSABLE meshes — one // mesh per atlas bucket (so all share a material and draw once), tagged `noBatch` so batch.js leaves // them intact (they're already one draw each). Each mesh carries `buyItems:[{item,center,vStart}]` and // a `sold` list; collapseBuyItem() zero-areas a bought item's quad in place. Records stay the dig; this // is book spines + toy boxes. Returns [] on merge failure → caller falls back to plain realSleeveMesh. // The merged geometries are ctx-tracked → freed with the room (leak-free). export function buildBuyableShelf(ctx, pack, placements) { const THREE = ctx.THREE; const byAtlas = new Map(); // material.uuid → { material, pls:[] } for (const pl of placements) { const mat = pack.material(pl.item); if (!mat) continue; let b = byAtlas.get(mat.uuid); if (!b) { b = { material: mat, pls: [] }; byAtlas.set(mat.uuid, b); } b.pls.push(pl); } const out = []; for (const b of byAtlas.values()) { const geos = b.pls.map(pl => itemQuad(THREE, pack, pl)); let merged; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; } geos.forEach(g => g.dispose()); if (!merged) continue; ctx._geometries.add(merged); // freed at room dispose const mesh = new THREE.Mesh(merged, b.material); mesh.userData.noBatch = true; // already one draw — batch.js skips it mesh.userData.buyMesh = true; mesh.userData.buyItems = b.pls.map((pl, i) => ({ item: pl.item, center: { x: pl.x, y: pl.y, z: pl.z }, vStart: i * 4 })); mesh.userData.sold = []; out.push(mesh); } return out; } // Zero-area a bought item's quad (collapse its 4 verts to the item centre → invisible), in place, in // the merged buyable mesh. Leak-free (no geometry churn). `it` is an entry from mesh.userData.buyItems. export function collapseBuyItem(mesh, it) { const pos = mesh.geometry.attributes.position; for (let k = 0; k < 4; k++) pos.setXYZ(it.vStart + k, it.center.x, it.center.y, it.center.z); pos.needsUpdate = true; mesh.geometry.computeBoundingSphere(); if (mesh.userData.sold) mesh.userData.sold.push(it.vStart); }