110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a 110-row facing table. THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every historical interior number carried a phantom second room — 'toy', untouched this round, 'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228. Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61, pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69, toy 86->86 (+0, the control). The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured 44%-no-crate hole to 0/40. WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material, one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands ship and the source library is prompted FROM real labels — the adidas jacket renders a legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh). FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) — new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore. Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0; 960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts (sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops the limit constraints — a driver must clamp travel itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
237 lines
15 KiB
JavaScript
237 lines
15 KiB
JavaScript
// PROCITY Lane C — optional GLB-upgrade layer. Reads Lane E's web/assets/manifest.json and swaps a
|
|
// primitive fitting for a detailed depot GLB where one exists ("reserve the detailed GLB hero props
|
|
// for where the camera gets close" — RESEARCH). Strictly ADDITIVE and OFF by default:
|
|
// • the whole lane runs 100% on primitives with no manifest and no network (LANE_C acceptance);
|
|
// • enable per build with opts.useGLB (interiors.js), or globally via preloadManifest().
|
|
//
|
|
// Pattern (house law): promise-cached loader, PLACEHOLDER-PERSISTS — the primitive shows immediately
|
|
// and stays until the GLB resolves; if the depot is unreachable the primitive stays forever (never a
|
|
// crash, never a blank). The GLB is placed at real-metre scale (glb_law), yaw-corrected per kind, and
|
|
// clamped to the primitive's reserved footprint, so occupancy/paths already computed by layout.js stay valid.
|
|
|
|
import { loadGLB } from '../core/loaders.js';
|
|
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
|
|
|
|
// my fitting kind → manifest.fittings id (only kinds with a depot GLB; others stay primitive).
|
|
// NOTE: `counter` → `counter_till` (R5): Lane E shipped a proper ~1.6 m timber counter WITH a modelled
|
|
// beige till (procity_fit_counter_till_01.glb, base-origin). The old `counter` id is the 4 m till-less
|
|
// balcao — do NOT use it here. Because counter_till carries its own till, the separate cash_register
|
|
// counter-top attach is dropped whenever the counter GLB is live (see attachCounterTop) — no double till.
|
|
const KIND_TO_GLB = {
|
|
crate: 'record_crate', recordBin: 'record_crate', // R5: record_crate fixed by Lane E (sourceless-texture slots stripped) → bins upgrade now
|
|
metalShelf: 'wire_shelf', wallShelf: 'wire_shelf',
|
|
clothesRack: 'clothes_rack',
|
|
bookshelf: 'bookshelf',
|
|
cubeShelf: 'cube_shelf_wide',
|
|
trestleTable: 'work_table',
|
|
counter: 'counter_till', // R5: real counter-with-till (NOT the 4 m 'counter'/balcao)
|
|
// Round-4 hero props:
|
|
listeningCorner: 'listening_booth', // record store focal prop
|
|
fridge: 'drinks_fridge', // milk bar drinks fridge
|
|
arcadeCabinet: 'arcade_cabinet', // video store focal prop
|
|
// Round-6 orphans (published R5, mapped now):
|
|
glassCase: 'glass_case', // toy/pawn/dept/milkbar display case
|
|
magazineRack: 'magazine_rack', // milkbar/pawn/book magazine rack
|
|
crateStack: 'crate_stack', // record/opshop back-room dressing
|
|
};
|
|
|
|
// Per-kind facing correction (radians, about +Y). The GLBs follow glb_law (metres, +Y up, base at origin)
|
|
// but carry NO automatic facing (manifest.conventions.facing: "Lane B/C rotate directional props"). The
|
|
// z-major assets (bookshelf/cube/work_table are modelled wide along Z) need a quarter-turn so their width
|
|
// runs along the fitting's X, matching the primitive the layout placed. Tuned by on-screen validation.
|
|
const KIND_TO_YAW = {
|
|
bookshelf: Math.PI / 2,
|
|
cubeShelf: Math.PI / 2,
|
|
trestleTable: Math.PI / 2,
|
|
// new hero props — tuned on-screen in R4 (see LANE_C_GLB_VALIDATION.md)
|
|
listeningCorner: 0,
|
|
fridge: 0,
|
|
arcadeCabinet: 0,
|
|
counter: 0, // R5 counter_till — tuned on-screen
|
|
glassCase: 0, magazineRack: 0, crateStack: 0, // R6 orphans — tuned on-screen
|
|
};
|
|
|
|
// Counter-top GLB props: sit ON the counter's benchtop (not a floor swap). Each targets a counterTop
|
|
// attach slot exposed by the counter fitting. cash_register is the till stand-in on a PRIMITIVE counter
|
|
// (dropped when counter_till is live — that GLB has its own till); milkshake_mixer only on milk-bar counters.
|
|
const COUNTERTOP = [
|
|
{ id: 'cash_register', slot: 'till', hideTill: true, types: null, yaw: 0, maxFoot: 0.42 },
|
|
{ id: 'milkshake_mixer', slot: 'appliance', hideTill: false, types: ['milkbar'], yaw: 0, maxFoot: 0.42 },
|
|
];
|
|
|
|
let _manifestP = null;
|
|
// Fetch + cache the manifest. Returns null (not throw) if absent/unreachable → primitives everywhere.
|
|
export function preloadManifest(url = 'assets/manifest.json') {
|
|
return (_manifestP ||= fetch(url).then(r => (r.ok ? r.json() : null)).catch(() => null));
|
|
}
|
|
export function manifestReady() { return _manifestP && typeof _manifestP.then === 'function' ? _manifestP : Promise.resolve(null); }
|
|
|
|
// [R41 §41.4] AUTO-FACING for the kit. The 110 new fittings carry no facing convention
|
|
// (manifest.conventions.facing: "Lane B/C rotate directional props"), and several of them are the SAME
|
|
// MESH ROTATED 90° from a sibling — so a hand-tuned yaw table would be 110 rows of guesswork. Instead the
|
|
// yaw is MEASURED: the primitive placeholder was built at the manifest footprint, so if the GLB's own
|
|
// footprint is major-axis-swapped relative to the slot the grid reserved, it needs a quarter turn. Square
|
|
// props (aspect within 10%) are orientation-agnostic and get 0 — a rule, not a table.
|
|
function autoYaw(entry, fitting) {
|
|
const [ew, ed] = entry.footprint || [0, 0];
|
|
const fw = fitting.footprint.w, fd = fitting.footprint.d;
|
|
if (!ew || !ed || !fw || !fd) return 0;
|
|
if (Math.abs(ew - ed) / Math.max(ew, ed) < 0.1) return 0; // square: nothing to align
|
|
if (Math.abs(fw - fd) / Math.max(fw, fd) < 0.1) return 0; // slot is square: either way fits
|
|
return (ew > ed) === (fw > fd) ? 0 : Math.PI / 2;
|
|
}
|
|
|
|
// Load + seat one GLB inside a fitting group. Shared by the whole-fitting swap and the multi-part booth.
|
|
// `yaw` is decided by the caller (legacy kinds keep their hand-tuned KIND_TO_YAW; kit ids get autoYaw).
|
|
// `at` (optional) = { x, y, z } local placement — the booth's bench-top slots. Without it the GLB takes the
|
|
// fitting's own origin and is clamped to the footprint the occupancy grid reserved.
|
|
const refOf = (entry, manifest) => (manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`);
|
|
|
|
function seatGLB(ctx, fitting, entry, manifest, kind, yaw, at) {
|
|
return loadGLB(refOf(entry, manifest)).then(gltf => placeGLB(ctx, fitting, gltf, kind, yaw, at));
|
|
}
|
|
|
|
// Instantiate a loaded gltf into the fitting group. SPLIT OUT FROM THE FETCH ON PURPOSE (R41): the
|
|
// multi-part booths must attach their parts in DECLARATION order, not in whichever order the network
|
|
// finished — see upgradeFitting.
|
|
function placeGLB(ctx, fitting, gltf, kind, yaw, at) {
|
|
if (!gltf || ctx._disposed || !fitting.group.parent) return null;
|
|
const THREE = ctx.THREE;
|
|
const inst = skeletonClone(gltf.scene); // clone-safe even if skinned
|
|
inst.rotation.y = yaw || 0; // face before measuring
|
|
// Scale: GLBs are authored in metres (glb_law), so keep REAL-WORLD scale — only shrink if the prop
|
|
// would overflow the footprint the occupancy grid reserved (never inflate a small prop to fill a slot).
|
|
// A bench-top part is never clamped: it is already smaller than the bench by construction.
|
|
const bb = new THREE.Box3().setFromObject(inst);
|
|
const size = new THREE.Vector3(); bb.getSize(size);
|
|
const glbW = Math.max(size.x, size.z) || 1;
|
|
const s = at ? 1 : Math.min(1, Math.max(fitting.footprint.w, fitting.footprint.d) / glbW);
|
|
inst.scale.setScalar(s);
|
|
inst.position.set(at ? at.x : 0, (at ? at.y : 0) - bb.min.y * s, at ? at.z : 0); // plant on the surface
|
|
inst.userData = { glbUpgrade: true, kind };
|
|
fitting.group.add(inst);
|
|
return inst;
|
|
}
|
|
|
|
// Upgrade one placed fitting in-place, if the manifest maps its kind (or the fitting names its own id).
|
|
// No-op when disabled/unavailable. `manifest` is the resolved object (or null). ctx is the room build
|
|
// context (for the _disposed guard). Returns a promise that settles when the swap is done (or immediately
|
|
// when there is nothing to do), so callers can await a room's GLB upgrades (room.glbReady).
|
|
// Resolution: production uses `depot:<file>` (loaders.js → depot CDN / local-depot). A manifest may set
|
|
// `localBase` (e.g. "assets/_local_glb/") to load straight from a served local dir — used for offline
|
|
// GLB validation before the depot is reachable.
|
|
export function upgradeFitting(ctx, fitting, kind, manifest) {
|
|
if (!manifest || !manifest.fittings) return Promise.resolve();
|
|
|
|
// [R41] MULTI-PART fittings (the DJ booths): several GLBs standing on one primitive bench. The bench
|
|
// frame is hidden exactly as a single swap hides it, so the booth reads as gear-on-a-desk either way.
|
|
if (fitting.glbParts && fitting.glbParts.length) {
|
|
const parts = fitting.glbParts
|
|
.map(p => ({ p, e: manifest.fittings[p.id] }))
|
|
.filter(o => o.e && o.e.file);
|
|
if (!parts.length) return Promise.resolve();
|
|
// FETCH IN PARALLEL, ATTACH IN DECLARATION ORDER. Attaching inside each load's own `.then` made the
|
|
// booth's children land in whichever order the network finished, which is not a function of the seed:
|
|
// two runs of the same 240 rooms differed on exactly the three types that have a booth (record, pawn,
|
|
// pub), and on nothing else. Same objects, same transforms, different child order — enough to break
|
|
// "byte-equal across two builds", which is the law this lane is held to. Load is concurrent; the
|
|
// second pass is a plain in-order loop, so it is deterministic by construction, not by luck.
|
|
return Promise.all(parts.map(o => loadGLB(refOf(o.e, manifest)))).then(gltfs => {
|
|
if (ctx._disposed) return;
|
|
let any = false;
|
|
for (let i = 0; i < parts.length; i++)
|
|
if (placeGLB(ctx, fitting, gltfs[i], parts[i].p.id, parts[i].p.ry || 0, parts[i].p)) any = true;
|
|
// Hide the GEAR stand-ins only — the bench they stand on IS the fitting and has no GLB of its own.
|
|
if (any) for (const c of fitting.group.children) if (c.userData && c.userData.boothGear) c.visible = false;
|
|
});
|
|
}
|
|
|
|
// [R41] a fitting may name its OWN manifest id (`fitting.glbId`, chosen on its seeded stream from a
|
|
// kit41 POOL). That is what turns one primitive builder into 110 distinct shop fittings. The legacy
|
|
// per-kind map is the fallback for the pre-R41 fittings, which name no id.
|
|
const id = fitting.glbId || KIND_TO_GLB[kind];
|
|
const entry = id && manifest.fittings[id];
|
|
if (!entry || !entry.file) return Promise.resolve();
|
|
|
|
// Facing: a pre-R41 kind keeps its hand-tuned yaw EXACTLY (KIND_TO_YAW[kind] || 0 — the R4/R6 on-screen
|
|
// validation); only a kit id, which has no tuned row and could be either half of a rotated pair, is
|
|
// auto-faced. Legacy rooms are therefore byte-identical on screen.
|
|
const yaw = fitting.glbId ? autoYaw(entry, fitting) : (KIND_TO_YAW[kind] || 0);
|
|
return seatGLB(ctx, fitting, entry, manifest, fitting.glbId ? id : kind, yaw).then(inst => {
|
|
if (inst) hidePrimitive(fitting);
|
|
});
|
|
}
|
|
|
|
// Hide the primitive FRAME (fixture) but keep the procedural STOCK visible on top of the detailed GLB
|
|
// shelf/counter. Post-batch (batch.js) the children are merged meshes tagged isStock / isFrame; pre-batch
|
|
// they're individual meshes (stock tagged isStock, frame untagged). Hide everything that isn't stock and
|
|
// isn't a GLB we added.
|
|
//
|
|
// [R41 §41.4 — A PRE-EXISTING BUG THIS ROUND WALKED INTO] `buyMesh` was missing from that list. R9's
|
|
// buy-anywhere shelves (stockpack.buildBuyableShelf) are merged, per-item-addressable meshes tagged
|
|
// `{ noBatch, buyMesh }` and NOT `isStock` — because batch.js already skips them on `noBatch` — so on any
|
|
// fitting whose kind has a GLB (bookshelf, cubeShelf, clothesRack) the swap turned every real,
|
|
// buyable item in the room INVISIBLE. It never showed up because `?stock=real` is opt-in and R9 measured
|
|
// it with GLBs off; R41 found it the moment 52 real garments went on a clothes rail and the rail came
|
|
// back empty. Fixed here rather than by tagging them isStock, because the tag is what tells batch.js
|
|
// how to treat a mesh and these must stay unbatched.
|
|
function hidePrimitive(fitting) {
|
|
for (const c of fitting.group.children) {
|
|
if (c.userData && (c.userData.isStock || c.userData.buyMesh || c.userData.glbUpgrade)) continue;
|
|
c.visible = false;
|
|
}
|
|
}
|
|
|
|
// Place counter-top GLB props (cash_register, milkshake_mixer) onto a counter fitting's benchtop.
|
|
// Additive: does NOT hide the counter primitive frame (upgradeFitting does that when counter is GLB-live).
|
|
// `counterHasGlb`: when the counter itself upgrades to counter_till (which carries its OWN modelled till),
|
|
// cash_register is SKIPPED here to avoid a double till — it only stands in on the primitive counter. The
|
|
// benchtop surface is ~0.10 m higher on the taller GLB counter, so counter-top items are lifted to match.
|
|
// Fail-soft: unreachable/missing GLB → primitive stays.
|
|
export function attachCounterTop(ctx, fitting, manifest, shopType, counterHasGlb) {
|
|
const ct = fitting.counterTop;
|
|
if (!ct || !manifest || !manifest.fittings) return Promise.resolve();
|
|
const topY = ct.y + (counterHasGlb ? 0.10 : 0); // GLB counter_till (~1.13) sits above the primitive (~1.03)
|
|
const jobs = [];
|
|
for (const spec of COUNTERTOP) {
|
|
if (spec.types && !spec.types.includes(shopType)) continue;
|
|
if (spec.id === 'cash_register' && counterHasGlb) continue; // counter_till has its own till → no double
|
|
const entry = manifest.fittings[spec.id];
|
|
if (!entry || !entry.file) continue;
|
|
const ref = manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`;
|
|
jobs.push(loadGLB(ref).then(gltf => {
|
|
if (!gltf || ctx._disposed || !fitting.group.parent) return;
|
|
const THREE = ctx.THREE;
|
|
const inst = skeletonClone(gltf.scene);
|
|
inst.rotation.y = spec.yaw || 0;
|
|
const bb = new THREE.Box3().setFromObject(inst);
|
|
const size = new THREE.Vector3(); bb.getSize(size);
|
|
const glbW = Math.max(size.x, size.z) || 1;
|
|
const s = Math.min(1, spec.maxFoot / glbW); // clamp to the benchtop slot; never inflate
|
|
inst.scale.setScalar(s);
|
|
const at = ct[spec.slot];
|
|
inst.position.set(at.x, topY - bb.min.y * s, at.z); // plant base on the benchtop surface
|
|
if (spec.hideTill && !counterHasGlb) for (const c of fitting.group.children) if (c.userData && c.userData.tillPrimitive) c.visible = false;
|
|
inst.userData = { glbUpgrade: true, kind: spec.id, counterTop: true };
|
|
fitting.group.add(inst);
|
|
}));
|
|
}
|
|
return Promise.all(jobs);
|
|
}
|
|
|
|
// Upgrade every placed fitting in a room. Called from buildInterior when GLB mode is on.
|
|
// `shopType` gates counter-top props (e.g. milkshake_mixer → milk bar only).
|
|
// Returns a promise resolving once all in-room fittings' GLB swaps have settled.
|
|
export function upgradeRoom(ctx, placed, manifest, shopType) {
|
|
if (!manifest) return Promise.resolve();
|
|
// counter_till carries its own till → cash_register attach is dropped when the counter GLB is present.
|
|
const counterHasGlb = !!(manifest.fittings && manifest.fittings[KIND_TO_GLB.counter]);
|
|
const jobs = [];
|
|
for (const p of placed) {
|
|
if (p.removed) continue;
|
|
jobs.push(upgradeFitting(ctx, p.fitting, p.kind, manifest));
|
|
if (p.fitting.counterTop) jobs.push(attachCounterTop(ctx, p.fitting, manifest, shopType, counterHasGlb));
|
|
}
|
|
return Promise.all(jobs);
|
|
}
|