Round-4 §Lane C — map the 4 new hero props + cash_register (manifest now
14 fittings), validate, measure, clean up.
- glb.js: map listening_booth→listeningCorner (record), drinks_fridge→fridge
(milkbar), arcade_cabinet→new arcadeCabinet primitive (video focal prop).
New attachCounterTop() places cash_register on every counter's till slot
(hides the primitive till) + milkshake_mixer on the milk-bar benchtop;
counter primitive + keeper stand untouched. All yaw 0 (no correction).
- fittings.js: new arcadeCabinet primitive (footprint/height matched to the
GLB); counter exposes counterTop attach points + tags the primitive till.
- theme.js: arcadeCabinet in the video recipe; the 3 focal props (arcade,
listeningCorner already, +) raised to priority 6 + placed first so the
door-counter path loop can't pull them → 24/24 appearance.
- interiors.js: thread recipe.key to upgradeRoom (gates milkshake_mixer to
milk bars).
- interior_test.html: load GLBs via the live depot: path (drop the deleted
_local_glb localBase shim; ?localdepot=1 still works offline).
Validated (all via live depot): primitive sweep 810 {throws 0, pathFail 0,
detFail 0}; GLB-on soak {throws 0, path 0, det 0, leakGeo 0, leakTex 0,
worst 7.3ms}; all 5 new props render planted/scaled/facing right (contact
sheet docs/shots/laneC/glb_heroprops_r4.jpg). Room tris measured: milk-bar
GLB-on ~468k (drinks_fridge 77k + milkshake_mixer 73k) — reported to Lane E
as the #1 bake-pass target (not fixed, per integrator decision #2; interiors
render one-at-a-time so gate 3 is unaffected). record_crate still broken +
counter still oversized — both await Lane E re-export (C task 5). Deleted the
stale web/assets/_local_glb/ validation copy. qa.sh --strict GREEN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
8.4 KiB
JavaScript
145 lines
8.4 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` is intentionally NOT mapped — the counter GLB (procity_fit_counter_01.glb) is a real
|
||
// 4.0 m-long × 1.1 m asset that squashes to ~0.6 m when fit to the ~2.2 m interior counter slot and has
|
||
// no till modelled; the primitive counter (correct height + till + keeper stand) reads better. Reported
|
||
// to Lane E (see C-progress.md / LANE_C_GLB_VALIDATION.md). Re-map here once a ~2 m counter asset lands.
|
||
const KIND_TO_GLB = {
|
||
crate: 'record_crate', recordBin: 'record_crate', // record_crate GLB currently fails to load → fail-soft to primitive (reported)
|
||
metalShelf: 'wire_shelf', wallShelf: 'wire_shelf',
|
||
clothesRack: 'clothes_rack',
|
||
bookshelf: 'bookshelf',
|
||
cubeShelf: 'cube_shelf_wide',
|
||
trestleTable: 'work_table',
|
||
// Round-4 hero props (manifest now 14 fittings):
|
||
listeningCorner: 'listening_booth', // record store focal prop
|
||
fridge: 'drinks_fridge', // milk bar drinks fridge
|
||
arcadeCabinet: 'arcade_cabinet', // video store focal prop
|
||
};
|
||
|
||
// 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-top GLB props: sit ON the primitive counter's benchtop (not a floor swap). Each targets a
|
||
// counterTop attach slot exposed by the counter fitting. cash_register replaces the primitive till on
|
||
// every counter; 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); }
|
||
|
||
// Upgrade one placed fitting in-place, if the manifest maps its kind. 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 (buildInterior exposes this as 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();
|
||
const id = KIND_TO_GLB[kind];
|
||
const entry = id && manifest.fittings[id];
|
||
if (!entry || !entry.file) return Promise.resolve();
|
||
|
||
const ref = manifest.localBase ? `${manifest.localBase}${entry.file}` : `depot:${entry.file}`;
|
||
return loadGLB(ref).then(gltf => {
|
||
if (!gltf || ctx._disposed || !fitting.group.parent) return; // room rebuilt / disposed mid-flight
|
||
const THREE = ctx.THREE;
|
||
const inst = skeletonClone(gltf.scene); // clone-safe even if skinned
|
||
// Facing: rotate the directional prop to match the primitive the layout placed (before measuring).
|
||
inst.rotation.y = KIND_TO_YAW[kind] || 0;
|
||
// 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).
|
||
const bb = new THREE.Box3().setFromObject(inst);
|
||
const size = new THREE.Vector3(); bb.getSize(size);
|
||
const want = fitting.footprint;
|
||
const glbW = Math.max(size.x, size.z) || 1;
|
||
const s = Math.min(1, Math.max(want.w, want.d) / glbW);
|
||
inst.scale.setScalar(s);
|
||
inst.position.y -= bb.min.y * s; // plant on the floor (glb_law minY≈0)
|
||
// hide the primitive FRAME (the first frameCount children) but keep procedural stock — which was
|
||
// added after the frame — visible on top of the detailed GLB shelf/counter.
|
||
const frameCount = fitting.frameCount ?? fitting.group.children.length;
|
||
for (let i = 0; i < frameCount && i < fitting.group.children.length; i++) fitting.group.children[i].visible = false;
|
||
inst.userData = { glbUpgrade: true, kind };
|
||
fitting.group.add(inst);
|
||
});
|
||
}
|
||
|
||
// Place counter-top GLB props (cash_register, milkshake_mixer) onto a counter fitting's benchtop.
|
||
// Additive: does NOT hide the counter primitive (correct height + keeper stand stay); cash_register
|
||
// hides just the primitive till it replaces. Fail-soft: unreachable/missing GLB → primitive stays.
|
||
export function attachCounterTop(ctx, fitting, manifest, shopType) {
|
||
const ct = fitting.counterTop;
|
||
if (!ct || !manifest || !manifest.fittings) return Promise.resolve();
|
||
const jobs = [];
|
||
for (const spec of COUNTERTOP) {
|
||
if (spec.types && !spec.types.includes(shopType)) continue;
|
||
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, ct.y - bb.min.y * s, at.z); // plant base on the benchtop surface
|
||
if (spec.hideTill) 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();
|
||
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));
|
||
}
|
||
return Promise.all(jobs);
|
||
}
|