John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
18 KiB
JavaScript
325 lines
18 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';
|
||
import { realSleeveMesh, buildBuyableShelf } from './stockpack.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;
|
||
}
|
||
|
||
// Which pack items a slot may draw from. A multi-slot pack (R41's wardrobe) hands the caller an already
|
||
// filtered `pool`; a single-slot pack (record/book/toy) hands back `pack.items` by reference, and those
|
||
// keep the pre-R41 behaviour exactly — one seeded atlas bucket, one r() draw, one draw call.
|
||
function slotItems(pack, src, r) {
|
||
return (src && src.pool && src.pool !== pack.items) ? src.pool : pack.atlasPool(r);
|
||
}
|
||
|
||
// ── 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). With ?stock=real the
|
||
// adapter hands back a pack pool → every sleeve is a real cover PLANE sampling the shared atlas (batches
|
||
// to one draw); otherwise procedural cardboard.
|
||
//
|
||
// [R42 §42.2b] Two options, both absent on every pre-R42 slot and both byte-identical when absent:
|
||
// `slot.axis === 'x'` lay the run along local X instead of local Z. A bin is a row you look INTO
|
||
// from the side (Z); a rack in the front of a DJ booth is a row you look AT from the front, and
|
||
// the sleeve face is already a plane in XY (normal +Z), so the same fan reads face-out with no
|
||
// new geometry. Same number of r() draws either way — the stream shape does not move.
|
||
// `slot.pool` one pooled face material for the WHOLE row instead of a per-sleeve tint. That is what
|
||
// makes the booth's bay cost ~0 draws: one material = one bucket in batch.js, and in a record
|
||
// shop it merges into the bucket the room's bins already have. (The R39 lesson, applied to a fan.)
|
||
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 alongX = slot.axis === 'x';
|
||
const step = run / Math.max(1, n);
|
||
const src = opts.adapter && opts.adapter(opts.shop, 'sleeve');
|
||
const pack = src && src.real ? src.pack : null;
|
||
const packItems = pack ? (src.pool || pack.items) : null; // [R41] slot-filtered when the pack has slots
|
||
const pool = pack ? null : facePool(ctx, 'card', r);
|
||
// `slot.pool`: a small SHARED set of faces for the whole row, drawn ONCE before the loop and cycled.
|
||
// Three, not one: one material is the cheapest possible row but it renders a rack of identical
|
||
// sleeves, which is worse than the fault this round is fixing. Three is 3 buckets worst case, and in
|
||
// a record shop they are buckets the room's bins already have, so it costs nothing there at all.
|
||
const shared = (!pack && slot.pool)
|
||
? Array.from({ length: 3 }, () => {
|
||
const m = ctx.mat('#ffffff', 0.7);
|
||
m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
return m;
|
||
})
|
||
: null;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = -run / 2 + (i + 0.5) * step;
|
||
let sl;
|
||
if (pack) {
|
||
sl = realSleeveMesh(ctx, pack, pick(r, packItems), fw, h); // isStock + shared atlas material
|
||
const j = (r() - 0.5) * 0.02;
|
||
sl.position.set(slot.x + (alongX ? t : j), slot.y + h / 2, slot.z + (alongX ? j : t));
|
||
parent.add(sl);
|
||
} else {
|
||
const front = i >= n - 4; // front sleeves show a cover
|
||
const faceMat = shared ? shared[i % shared.length]
|
||
: (front ? ctx.mat('#ffffff', 0.7) : ctx.mat(pick(r, CARD), 0.85));
|
||
if (!shared && front) { faceMat.map = pick(r, pool); faceMat.color.set('#ffffff'); faceMat.needsUpdate = true; }
|
||
const j = (r() - 0.5) * 0.02;
|
||
sl = ctx.box(fw, h, 0.012, faceMat,
|
||
slot.x + (alongX ? t : j), slot.y + h / 2, slot.z + (alongX ? j : t), parent);
|
||
sl.userData.isStock = true;
|
||
}
|
||
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);
|
||
// ?stock=real: book spines / toy boxes pull real covers from the pack (shared atlas material → batched).
|
||
const src = !scatter && opts.adapter && opts.adapter(opts.shop, kind);
|
||
const pack = src && src.real ? src.pack : null;
|
||
|
||
if (scatter) { // tables / pegboards: random small goods
|
||
for (let i = 0; i < n; i++) {
|
||
const s = 0.09 + r() * 0.11;
|
||
// [R39] `slot.pool` — keep every face on a POOLED texture at white, exactly as the spine and
|
||
// shelf-box rows already do, so a scattered pile MERGES into the room's existing stock buckets
|
||
// instead of minting one bucket (= one draw) per item. Measured on the rummage bin: 9 tinted
|
||
// scatter items cost 9 draws in a room with no other scatter; pooled they cost 0–1. Absent ⇒
|
||
// the pre-R39 tinted mix, and the stream shape is unchanged either way (both branches draw
|
||
// tint, then the coin, then the face only when mapped) — trestle/pegboard are byte-identical.
|
||
const tint = pick(r, CARD);
|
||
const mapped = r() < 0.5 || !!slot.pool;
|
||
const m = ctx.mat(slot.pool ? '#ffffff' : tint, 0.85);
|
||
if (mapped) { 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;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (kind === 'spine') { // books / VHS: thin spines edge-out
|
||
const step = (run - 0.04) / n;
|
||
if (pack) { // real book spines → one buyable, addressable mesh
|
||
const poolItems = slotItems(pack, src, 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;
|
||
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;
|
||
}
|
||
|
||
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;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// default 'box' / 'snack': upright cartons in a row, faces forward (+Z).
|
||
// SINGLE material (cover on all faces) so the whole run merges to one draw in batch.js — a
|
||
// 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;
|
||
if (pack) { // real toy boxes → one buyable, addressable mesh
|
||
const poolItems = slotItems(pack, src, 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;
|
||
const it = pick(r, poolItems);
|
||
// [R41] a cut-out item (a bucket hat, a pair of thongs) takes its OWN width from its atlas cell;
|
||
// a cover-art item has no `aspect` and keeps the fixed carton width it always had.
|
||
const iw = it.aspect ? Math.min(bw * 0.98, bh * it.aspect) : bw * 0.88;
|
||
pls.push({ item: it, x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: iw, 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;
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Garments hung along a rail.
|
||
//
|
||
// [R41 §41.4 — ROUTE R5, THE WARDROBE, ZERO GEOMETRY] With the op-shop pack loaded (`?stock=real`), every
|
||
// garment on this rail is a real 1990s Australian layer off ONE shared atlas — so a rail of twelve is one
|
||
// draw, exactly like the record bins, and the op shop's clothes finally have names the buy card can show.
|
||
// The garment's WIDTH comes from its own atlas cell aspect, not a constant: a pair of phat pants is wider
|
||
// than a ribbed singlet, and forcing both into a 0.34 m plane was the tell that gave the old placeholder
|
||
// away. No pack ⇒ the pooled procedural canvas, unchanged, byte-for-byte (this is the ?noassets path).
|
||
function hang(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
|
||
const src = opts.adapter && opts.adapter(opts.shop, 'garment');
|
||
const pack = src && src.real ? src.pack : null;
|
||
if (pack) {
|
||
const items = src.pool || pack.items;
|
||
const pls = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const it = pick(r, items);
|
||
const gh = h * (0.9 + r() * 0.2);
|
||
const gw = gh * (it.aspect || 0.66);
|
||
pls.push({ item: it, x: slot.x - run / 2 + run * (i / (n - 1 || 1)), y: slot.y - 0.05 - gh / 2,
|
||
z: slot.z + (r() - 0.5) * 0.03, w: gw, h: gh, ry: (r() - 0.5) * 0.15 });
|
||
}
|
||
const meshes = buildBuyableShelf(ctx, pack, pls); // addressable → the shelf buy card reads it
|
||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||
}
|
||
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;
|
||
p.userData.isStock = true;
|
||
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;
|
||
b.userData.isStock = true;
|
||
}
|
||
}
|