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>
This commit is contained in:
m3ultra 2026-07-15 09:56:21 +10:00
parent 5ae635a1e9
commit c68bba8f32
6 changed files with 265 additions and 28 deletions

View File

@ -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[]` — 13 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.
---

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@ -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 =
`<span class="x">×</span><h3>${it.title || 'Untitled'}</h3>` +
`<div class="sub">${it.artist ? it.artist + ' · ' : ''}${(it.price_band || 'stock')}</div>` +
`<div class="price">$${price}</div>` +
`<button ${afford ? '' : 'disabled'}>${afford ? 'BUY IT' : 'NOT ENOUGH CASH'}</button>`;
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 += ' · <b>E</b> bin/shelf · <b>I</b> 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();

View File

@ -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;
}
}

View File

@ -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<pack|null>
@ -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);
}