PROCITY/web/js/interiors/glb.js
m3ultra 34926324bd Lane C R4: hero props into rooms (arcade/booth/fridge/mixer/register)
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>
2026-07-14 18:44:09 +10:00

145 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}