PROCITY/web/js/interiors/glb.js
m3ultra fe40ea7d0a Lane C: round-3 GLB hero-prop validation (useGLB path)
Validated Lane E's 9 fitting GLBs in real rooms. 5 good, now live behind useGLB (wire_shelf, clothes_rack, bookshelf, cube_shelf_wide, work_table); record_crate broken (won't load) + counter too long/no-till fall back to primitive. Report: docs/LANES/LANE_C_GLB_VALIDATION.md.

glb.js: real-metre scale clamped to footprint, per-kind yaw for Z-major assets (+90deg) per manifest facing convention, manifest.localBase for offline validation, awaitable upgrades. interiors.js: room.glbReady. interior_test.html: GLB toggle.

Soak useGLB on (50 rooms): throws 0, pathFail 0, detFail 0, leakGeo 0, leakTex 0, worst 10.5ms, 256 upgrades. qa.sh --strict green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:18:12 +10:00

90 lines
5.6 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',
};
// 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,
};
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);
});
}
// Upgrade every placed fitting in a room. Called from buildInterior when GLB mode is on.
// Returns a promise resolving once all in-room fittings' GLB swaps have settled.
export function upgradeRoom(ctx, placed, manifest) {
if (!manifest) return Promise.resolve();
return Promise.all(placed.filter(p => !p.removed).map(p => upgradeFitting(ctx, p.fitting, p.kind, manifest)));
}