diff --git a/C-progress.md b/C-progress.md index 9ebc4a6..c1dbb47 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,43 @@ *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* -Last updated: 2026-07-15 (round 8) · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere + +Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake + +working code; C2 widens buying beyond the dig. + +**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes +`room.browsePoints[]` — 1–3 deterministic floor poses in front of browsable fittings, facing the goods, +where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as +`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law +is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from +spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per +`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2 +gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in +[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed +first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via +fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the +`path` overlay + a HUD count. + +**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**; +records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged, +per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded +atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a +pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to +inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one +draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack. +Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card +closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 · +book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement +determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg). + +**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first +`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present + +valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together. --- diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md index ecc7036..df47cb0 100644 --- a/docs/LANES/LANE_C_NOTES.md +++ b/docs/LANES/LANE_C_NOTES.md @@ -1,9 +1,14 @@ # LANE C — cross-lane notes (PROCITY-C) -## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 handshake — AGREE BEFORE CODE) +## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live) -This is decision #1's contract, published *first* so D and F can build in parallel. It is the seam the -whole round hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.** +**Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref +`lane-c/round9-browse`). The contract below is final — build against it. It is the seam the whole round +hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.** + +**F smoke (warn):** enter a `?stock=real` book/toy/record shop, assert `room.browsePoints.length >= 1` +and every point walkable+reachable (or just trust C's asserts and check `.length` + that D's rig count == +`min(occupancy,3)`). Test page: the `path` overlay draws each point as a cyan post + facing nose. ### What C exposes on the room API (live once C1 commits) @@ -52,6 +57,24 @@ don't care (a closed shop simply isn't entered). No blocker on C. If any field above doesn't fit D's rig spawner or F's wiring, flag it here **before** I finalize — but the shape mirrors `counter.stand` exactly, which both of you already consume, so it should slot straight in. +## → Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn) + +Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a +merged, per-item-addressable mesh: `mesh.userData.buyMesh === true`, `mesh.userData.buyItems = [{item, +center, vStart}]`, one draw per shelf (drawSweep with `?stock=real`: record 41 · book 58 · toy 57, ≤350). +Smoke recipe (headless — no raycast needed): +```js +const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' }); +let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; }); +assert(mesh); // real book/toy shop has ≥1 buy mesh +const it = mesh.userData.buyItems[0]; // {item:{title,artist,price,price_band}, center, vStart} +// buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it); +// assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free +``` +Test page exposes `shelfBuySoak()` (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and +`shelfUnderAim()/showShelfCard()/buyShelfOffer()` for the live raycast path (E key: bin→dig, shelf→card). +Parody stock (no pack) has **no** buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack. + ## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed) **Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM: diff --git a/docs/shots/laneC/browse_buy_r9.jpg b/docs/shots/laneC/browse_buy_r9.jpg new file mode 100644 index 0000000..0bd1ff2 Binary files /dev/null and b/docs/shots/laneC/browse_buy_r9.jpg differ diff --git a/web/interior_test.html b/web/interior_test.html index 37d2793..ee30576 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -72,6 +72,7 @@ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.j import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js'; import { createDig, binSeed } from './js/interiors/dig.js'; import { createWallet } from './js/interiors/wallet.js'; +import { collapseBuyItem } from './js/interiors/stockpack.js'; // ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam. const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real'; @@ -420,6 +421,107 @@ async function digSoak(N = 15) { return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT }; } +// ── buy-anywhere (round 9 C2): aim at a real-stock book spine / toy box → pull card → BUY ── +// Records keep the deep dig; shelves get a light pull-and-buy over the SAME wallet. Reuses the +// batched-but-addressable buy meshes from stockpack.buildBuyableShelf. Parody stock has no buy mesh +// → no card ("not for sale"). UI is test-page-local; the reusable pieces are the mesh data + wallet. +const _buyRay = new THREE.Raycaster(); +const _lp = new THREE.Vector3(); +const shelfPanel = document.createElement('div'); shelfPanel.id = 'shelfBuy'; +const shelfCss = document.createElement('style'); shelfCss.textContent = ` + #shelfBuy{position:fixed;top:50%;right:26px;transform:translateY(-50%);z-index:72;width:230px;background:rgba(18,15,10,.96);border:1px solid #b58b3a;border-radius:10px;padding:14px 15px;font:12px "Courier New",monospace;color:#e8e2d4;display:none} + #shelfBuy h3{margin:0 0 2px;font-size:15px;color:#ffd75e} + #shelfBuy .sub{color:#b8b0a0;margin-bottom:8px} + #shelfBuy .price{font-size:20px;color:#3dff8b;margin-bottom:10px} + #shelfBuy button{width:100%;padding:9px;background:#1b2436;border:1px solid #3d5273;color:#cfe0ff;font:700 13px "Courier New",monospace;border-radius:6px;cursor:pointer} + #shelfBuy button:disabled{opacity:.45;cursor:not-allowed} + #shelfBuy .x{position:absolute;top:8px;right:11px;color:#8a8272;cursor:pointer}`; +document.head.appendChild(shelfCss); document.body.appendChild(shelfPanel); +let shelfOffer = null; // { mesh, item } currently on the card + +// Collect buy meshes in the live room, raycast from crosshair, return the nearest unsold item. +function shelfUnderAim(maxDist = 3.0) { + if (!current) return null; + const meshes = []; current.group.traverse(o => { if (o.userData && o.userData.buyMesh && o.userData.buyItems.length) meshes.push(o); }); + if (!meshes.length) return null; + _buyRay.setFromCamera({ x: 0, y: 0 }, camera); + const hit = _buyRay.intersectObjects(meshes, false)[0]; + if (!hit || hit.distance > maxDist) return null; + const mesh = hit.object; + _lp.copy(hit.point); mesh.worldToLocal(_lp); // hit → mesh-local (== item centre space) + let best = null, bd = Infinity; + for (const it of mesh.userData.buyItems) { const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2; if (d < bd) { bd = d; best = it; } } + return best ? { mesh, item: best } : null; +} +const priceOf = (it) => it.price || (it.price_band === 'grail' ? 60 : it.price_band === 'collector' ? 30 : 8); +function showShelfCard(offer) { + shelfOffer = offer; const it = offer.item.item; + const price = priceOf(it), afford = wallet.canBuy(price); + shelfPanel.style.display = 'block'; + shelfPanel.innerHTML = + `×

${it.title || 'Untitled'}

` + + `
${it.artist ? it.artist + ' · ' : ''}${(it.price_band || 'stock')}
` + + `
$${price}
` + + ``; + shelfPanel.querySelector('.x').onclick = closeShelfCard; + shelfPanel.querySelector('button').onclick = () => buyShelfOffer(); +} +function closeShelfCard() { shelfPanel.style.display = 'none'; shelfOffer = null; } +// Buy the carded item: debit wallet, collapse its quad (removed from the shelf), drop it from the pool. +function buyShelfOffer() { + if (!shelfOffer) return false; + const { mesh, item } = shelfOffer, it = item.item, price = priceOf(it); + if (!wallet.buy({ t: it.title, a: it.artist, price, s: it.price_band })) return false; + collapseBuyItem(mesh, item); + const arr = mesh.userData.buyItems; const i = arr.indexOf(item); if (i >= 0) arr.splice(i, 1); + closeShelfCard(); + return true; +} +// E interacts: a record bin opens the dig; a real shelf raises the buy card. Works with the dig's E too. +if (STOCK_REAL || DIG_ON) { + addEventListener('keydown', e => { + if (e.code !== 'KeyE' || e.target.matches('input,select')) return; + if (dig && dig.active) return; + if (DIG_ON) { const b = binUnderAim(); if (b) { openDigOn(b); return; } } // bins → dig first + const off = shelfUnderAim(); if (off) showShelfCard(off); // shelves → buy card + }); + document.getElementById('hint').innerHTML += ' · E bin/shelf · I bag'; +} +// Buy-anywhere soak: for each shelf shop type, enter, buy one shelf item, assert wallet+collapse+leak-free. +async function shelfBuySoak() { + const richWallet = createWallet(7); + const realBuy = (o) => richWallet.buy(o); + // warmup: build+render+dispose one book & toy room so the SHARED pack atlases are resident (and thus + // counted in the baseline) — else they'd read as a false texture "leak" on first render (like soak()). + for (const t of ['book', 'toy']) { const w = buildInterior({ id: 'warm', type: t, seed: 5 }, THREE, { stock: 'real' }); scene.add(w.group); renderer.render(scene, camera); scene.remove(w.group); w.dispose(); } + await new Promise(r => setTimeout(r, 0)); + const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures; + const out = { types: {}, bought: 0, broke: 0, noMesh: 0, collapseOk: true }; + for (const type of ['book', 'toy']) { + for (const s of [1990, 7, 42]) { + const room = buildInterior({ id: type, type, seed: s }, THREE, { stock: 'real' }); + scene.add(room.group); renderer.render(scene, camera); + // find a buy mesh + an item; buy it directly (headless — no raycast needed) + let mesh = null; room.group.traverse(o => { if (!mesh && o.userData && o.userData.buyMesh && o.userData.buyItems.length) mesh = o; }); + if (!mesh) { out.noMesh++; out.types[type + '/' + s] = 'NO_BUY_MESH'; scene.remove(room.group); room.dispose(); continue; } + const before = mesh.userData.buyItems.length; const item = mesh.userData.buyItems[0]; const it = item.item; + const cashBefore = richWallet.cash(); const price = Math.min(priceOf(it), cashBefore); // never starve the soak + const posBefore = mesh.geometry.attributes.position.getX(item.vStart); + if (realBuy({ t: it.title, a: it.artist, price, s: it.price_band })) { + collapseBuyItem(mesh, item); mesh.userData.buyItems.splice(0, 1); + const pa = mesh.geometry.attributes.position; // all 4 verts must collapse to the centre (zero area) + for (let k = 0; k < 4; k++) if (Math.abs(pa.getX(item.vStart + k) - item.center.x) > 1e-4 || Math.abs(pa.getY(item.vStart + k) - item.center.y) > 1e-4) out.collapseOk = false; + out.bought++; + out.types[type + '/' + s] = { items: before, afterBuy: mesh.userData.buyItems.length, cashΔ: cashBefore - richWallet.cash() }; + } else { out.broke++; out.types[type + '/' + s] = 'BROKE'; } + scene.remove(room.group); room.dispose(); + } + } + renderer.render(scene, camera); + out.leakGeo = renderer.info.memory.geometries - bG; out.leakTex = renderer.info.memory.textures - bT; + return out; +} + // ── loop ─────────────────────────────────────────────────────────────────────── let last = performance.now(); function frame() { @@ -431,7 +533,8 @@ function frame() { // expose for headless verification (screenshot harness, workflow checks) window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer, - DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter }; + DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter, + shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak }; rebuild(); frame(); diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js index 1b40a69..6c0c46f 100644 --- a/web/js/interiors/stock.js +++ b/web/js/interiors/stock.js @@ -15,7 +15,7 @@ // items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline. import { pick } from './context.js'; -import { realSleeveMesh } from './stockpack.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']; @@ -165,19 +165,24 @@ function shelfLine(ctx, parent, slot, opts, r) { if (kind === 'spine') { // books / VHS: thin spines edge-out const step = (run - 0.04) / n; - for (let i = 0; i < n; i++) { + 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; - let b; - if (pack) { // real book spine: cover plane at the shelf front - b = realSleeveMesh(ctx, pack, pick(r, pack.items), step * 0.86, bh); - b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01); - parent.add(b); - } else { - const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; - b = ctx.box(step * 0.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent); - b.userData.isStock = true; - } + 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; @@ -199,20 +204,25 @@ function shelfLine(ctx, parent, slot, opts, r) { // 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++) { + 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; - if (pack) { // real toy box: cover plane at the carton front - const b = realSleeveMesh(ctx, pack, pick(r, pack.items), bw * 0.88, bh); - b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01); - parent.add(b); - } else { - 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; - } + 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; } } diff --git a/web/js/interiors/stockpack.js b/web/js/interiors/stockpack.js index e2c6563..fda013b 100644 --- a/web/js/interiors/stockpack.js +++ b/web/js/interiors/stockpack.js @@ -13,6 +13,7 @@ // 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 @@ -44,11 +45,21 @@ async function buildPack(type, idx, base, THREE) { 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]]; }, }; @@ -79,3 +90,57 @@ export function realSleeveMesh(ctx, pack, item, w, h) { 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); +}