PROCITY/web/js/interiors/stockpack.js
m3ultra c68bba8f32 Lane C round-9 C2: buy-anywhere — pull-and-buy on book spines + toy boxes
Book/toy shelves get pull-and-buy over the SAME wallet; records keep the
deep dig. New stockpack.buildBuyableShelf(): a real shelf is merged,
per-item-ADDRESSABLE meshes — one per atlas (all covers drawn from one
seeded atlas → one draw/shelf), tagged noBatch so batch.js leaves them
intact. Aim a spine/box → pull card (title/author-or-maker/$price/band
from the pack index) → BUY debits the wallet, adds to inventory, and
collapseBuyItem() zero-areas the item's quad in place (removed from the
shelf, no geometry churn). Parody stock → no buy mesh → no card. Fail-
soft per pack; atlasPool() keeps each shelf single-atlas (one draw).

Test page: E interacts (bin→dig, shelf→card) + a #shelfBuy pull card;
shelfBuySoak() buys one item per book/toy seed. Validated: raycast→card
→BUY end-to-end (quad collapses to centre, cash/inventory update),
buy-soak 6/6 collapseOk leak geo0/tex0, ?stock=real draws record 41/
book 58/toy 57 ≤350, drawSweep GLB-off 168/on 344 unchanged, placement
determinism 0 fails, qa --strict GREEN 5/5. Shot: browse_buy_r9.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:56:21 +10:00

147 lines
7.8 KiB
JavaScript

// 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_<type>_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();
const _packs = new Map(); // type → Promise<pack|null>
const _resolved = new Map(); // type → pack|null (sync accessor for buildInterior)
// Preload a pack (fetch index + atlas textures). Cached. Call once before building with ?stock=real.
export function preloadStockPack(type, { base = 'assets/models/', THREE = THREE_NS } = {}) {
if (_packs.has(type)) return _packs.get(type);
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(type, pack); return pack; });
_packs.set(type, p);
return p;
}
// Synchronous accessor — the resolved pack if preloaded, else null (buildInterior uses this).
export function getStockPack(type) { return _resolved.get(type) || 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);
}