PROCITY/web/js/interiors/glb.js
m3ultra aa9c140eca Lane C R5 C1: v1.1 re-map — record_crate (fixed) + counter_till
Round-5 §Lane C task 1 (the v1.1 gate). Map Lane E's R4-late re-exports in
glb.js:

- record_crate FIXED (E stripped sourceless AO/roughness slots) → crate/
  recordBin upgrade to plastic crates with the procedural sleeves inside;
  removed the stale "fails to load" comment.
- counter → 'counter_till' (the real ~1.6m timber counter WITH a modelled
  till; NOT the 4m balcao). Footprint-fit + floor-plant + keeper-stand pose
  unchanged.
- Double-till resolved (documented): counter_till carries its own till, so
  the separate cash_register attach is dropped whenever the counter GLB is
  live (falls back to cash_register on the primitive counter otherwise);
  milkshake_mixer stays on the milk-bar bench, lifted +0.10m onto the taller
  GLB counter.

Validated (GLB on, via depot): sweep+soak {throws 0, path 0, det 0, worst
10.4ms}, 0 rooms with a visible primitive till, leak-free vs a saturated
baseline (the transient 2geo/4tex first-sight is record_crate + counter_till
cache-fill). Primitive path byte-unchanged (glb.js-only edit). qa.sh --strict
GREEN. Contact sheet docs/shots/laneC/glb_v1_1_r5.jpg. → F can tag v1.1
(LANE_C_NOTES.md).

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

154 lines
9.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` → `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
};
// 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
};
// 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); }
// 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 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);
}