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>
This commit is contained in:
parent
fafd5a700a
commit
fe40ea7d0a
@ -3,7 +3,34 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-14 (b) · owner: PROCITY-C · reviewer: Fable
|
||||
Last updated: 2026-07-14 (round 3) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-14 (round 3) — GLB hero-prop visual validation
|
||||
|
||||
Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip `useGLB` on and validate Lane E's fitting GLBs in real
|
||||
rooms. Done — full report for Lane E in [docs/LANES/LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md).
|
||||
|
||||
**Verdict:** 5 of 9 fitting GLBs are good and now live behind `useGLB` (wire_shelf, clothes_rack,
|
||||
bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed
|
||||
(`record_crate` **broken — won't load**; `counter` **too long/no till** → primitive keeps the till +
|
||||
keeper stand). record bins stay primitive until Lane E re-exports the crate.
|
||||
|
||||
**Soak with `useGLB` on** (50 rooms, all types): `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
|
||||
leakTex 0 · worst build 10.5ms · 256 upgrades applied` — GLB resources dispose leak-free on room exit.
|
||||
|
||||
**glb.js changes (my file):**
|
||||
- `manifest.localBase` — load GLBs from a served local dir for offline validation (production still uses
|
||||
`depot:<file>`; forward-compatible with Lane E's local-depot mode).
|
||||
- **Real-metre scale, clamped** to the reserved footprint (was fit-to-width, which squashed tall props);
|
||||
matches `glb_law` (metres). Per-kind **yaw correction** (`KIND_TO_YAW`) for the Z-major assets
|
||||
(bookshelf/cube/work_table need +90°) per the manifest `facing` convention ("Lane B/C rotate directional props").
|
||||
- `room.glbReady` (interiors.js) — a promise that resolves when GLB swaps settle, so callers can await
|
||||
before screenshot/leak-check (Lane F can use it in the §3.4 wiring).
|
||||
- Test page (`interior_test.html`) gains a **GLB props** toggle + a `GLB props: N upgraded` HUD line.
|
||||
Local validation needs the GLBs copied in: `cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/`
|
||||
(untracked; real GLBs live on the depot). Screenshot grid: [docs/shots/laneC/glb_validation_grid.jpg](docs/shots/laneC/glb_validation_grid.jpg).
|
||||
|
||||
---
|
||||
|
||||
|
||||
53
docs/LANES/LANE_C_GLB_VALIDATION.md
Normal file
53
docs/LANES/LANE_C_GLB_VALIDATION.md
Normal file
@ -0,0 +1,53 @@
|
||||
# LANE C — GLB validation report (round 3) → for Lane E + Fable
|
||||
|
||||
*PROCITY-C, 2026-07-14. Round-3 task "GLB path visual validation" (ROUND3_INSTRUCTIONS §Lane C).
|
||||
Validated Lane E's 9 fitting GLBs from `pipeline/_normalized/` in real interior rooms via the
|
||||
`interiors/glb.js` upgrade path (`useGLB` on). Verdict below; the 5 "good" assets are live.*
|
||||
|
||||
## How validated
|
||||
- Served the 9 `procity_fit_*.glb` locally (`web/assets/_local_glb/`, copied from `pipeline/_normalized/`)
|
||||
and pointed `glb.js` at them via a new `manifest.localBase` (loads before/without the depot, per the
|
||||
round-3 instruction). Production still uses `depot:<file>` unchanged.
|
||||
- Toggle **GLB props** on the interior test page (`web/interior_test.html`) rebuilds every shop type
|
||||
with the GLBs and re-renders once they load; a headless sweep+soak checks throws/leaks/perf.
|
||||
- Convention followed: `glb_law` (metres, +Y up, base at origin) ✓ every asset measured `minY≈0`;
|
||||
`facing` ("Lane B/C rotate directional props") — Lane C now applies per-kind yaw in `glb.js`.
|
||||
|
||||
## Result — soak with `useGLB` on (50 rooms, all types)
|
||||
`throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 · leakTex 0 · worst build 10.5ms (<50) ·
|
||||
256 GLB upgrades applied`. **GLB textures/geometries dispose leak-free on room exit** (loader-cached
|
||||
+ clone-shared; baseline taken after warmup). Acceptance met.
|
||||
|
||||
## ✅ Good — live now (5 assets)
|
||||
| id / file | native (x·y·z m) | notes |
|
||||
|---|---|---|
|
||||
| `wire_shelf` · procity_fit_wire_shelf_01.glb | 1.17 · 1.8 · 0.44 | clean, full height, faces right (yaw 0) |
|
||||
| `clothes_rack` · procity_fit_clothes_rack_01.glb | 1.34 · 1.5 · 0.43 | garments hang correctly, casters read well |
|
||||
| `bookshelf` · procity_fit_bookshelf_01.glb | 0.2 · 2.0 · 1.02 | **needs +90° yaw** — modelled wide along Z; Lane C rotates it. Then spines face the aisle ✓ |
|
||||
| `work_table` · procity_fit_work_table_01.glb | 0.44 · 0.75 · 1.04 | **needs +90° yaw** (Z-major); goods sit on top ✓ |
|
||||
| `cube_shelf_wide` · procity_fit_cube_shelf_wide_01.glb | 0.75 · 1.1 · 1.24 | **needs +90° yaw**. ⚠ 1.24 wide → in narrow (2-col ≈0.84m) cube slots it clamps to ~0.75m tall. Fine in wide slots. Optional: a ~0.8m-wide cube variant. |
|
||||
|
||||
Lane C applies the +90° yaw for the three Z-major assets (per the `facing` convention — no action needed
|
||||
from Lane E unless you want to re-bake them Z-forward). All plant on the floor; no z-fighting with the
|
||||
procedural stock/decor that rides on top.
|
||||
|
||||
## ⚠ Mismatches to fix (2 assets)
|
||||
1. **`procity_fit_record_crate_01.glb` — BROKEN, will not load.**
|
||||
`GLTFLoader.loadAsync` throws `TypeError: Cannot read properties of undefined (reading 'uri')`.
|
||||
The file has valid glTF magic and is 858 KB, but references an undefined `uri` (missing embedded
|
||||
image/buffer, or an extension the shipping bare `core/loaders.js` GLTFLoader can't resolve — it is
|
||||
the **only one of the 16 that fails** that loader; the other 8 fittings + street furniture load fine).
|
||||
Impact: record bins/crates fall back to primitives (fail-soft, no crash). **Please re-export** (and if
|
||||
it uses KTX2/meshopt, flag it — the core loader has no KTX2/meshopt/Draco decoder configured).
|
||||
2. **`procity_fit_counter_01.glb` — too large for the interior counter slot; no till.**
|
||||
It's a real **4.02 m-long × 1.1 m** counter. Interior counter slots are ~1.4–2.2 m, so fitting to the
|
||||
slot squashes it to ~0.6 m tall, and it has no till/register modelled. Lane C therefore **keeps the
|
||||
counter primitive** (correct height + till + keeper stand). If you want a GLB counter for interiors,
|
||||
a **~2 m-long counter with a till** would drop straight in (re-add `counter` to `KIND_TO_GLB`).
|
||||
|
||||
## Notes for Lane E's local-depot mode (your round-3 §2)
|
||||
When `?localdepot=1` / `core/loaders.js` resolves `depot:<file>` → local, Lane C needs **no change**:
|
||||
`glb.js` uses `depot:<file>` by default and only used `localBase` as a standalone validation shim. Point
|
||||
your local resolver at `pipeline/_normalized/` and my `useGLB` path works through it as-is.
|
||||
|
||||
Screenshot evidence: [`docs/shots/laneC/glb_validation_grid.jpg`](../shots/laneC/glb_validation_grid.jpg).
|
||||
BIN
docs/shots/laneC/glb_validation_grid.jpg
Normal file
BIN
docs/shots/laneC/glb_validation_grid.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@ -50,6 +50,7 @@
|
||||
<label><input type="checkbox" id="tgWire"> wireframe</label>
|
||||
<label><input type="checkbox" id="tgGrid"> occupancy</label>
|
||||
<label><input type="checkbox" id="tgPath"> path</label>
|
||||
<label><input type="checkbox" id="tgGLB"> GLB props</label>
|
||||
</div>
|
||||
<div class="btnrow"><button id="soakBtn">▶ 50-room soak test</button></div>
|
||||
<div id="soak"></div>
|
||||
@ -99,7 +100,10 @@ for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
|
||||
|
||||
let current = null; // the live interior
|
||||
let gridHelper = null, pathHelper = null;
|
||||
let wire = false, showGrid = false, showPath = false;
|
||||
let wire = false, showGrid = false, showPath = false, useGLB = false;
|
||||
let glbManifest = null; // Lane E manifest + localBase → validate GLB props vs locally-served copies
|
||||
fetch('assets/manifest.json').then(r => r.ok ? r.json() : null)
|
||||
.then(m => { if (m) { m.localBase = 'assets/_local_glb/'; glbManifest = m; } }).catch(() => {});
|
||||
|
||||
function parseLot(v) {
|
||||
const m = String(v).match(/(\d+(?:\.\d+)?)\s*[x×,\s]\s*(\d+(?:\.\d+)?)/);
|
||||
@ -114,7 +118,7 @@ function rebuild() {
|
||||
const arch = archSel.value === 'auto' ? undefined : archSel.value;
|
||||
const lot = parseLot($('lot').value);
|
||||
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
|
||||
current = buildInterior(shop, THREE, { archetype: arch });
|
||||
current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined });
|
||||
scene.add(current.group);
|
||||
// place the player at the spawn
|
||||
camera.position.set(current.spawn.x, 1.6, current.spawn.z);
|
||||
@ -123,6 +127,8 @@ function rebuild() {
|
||||
if (showGrid) buildGridHelper();
|
||||
if (showPath) buildPathHelper();
|
||||
updateHUD();
|
||||
// GLB props load async — re-render + refresh HUD once they settle (rAF is throttled in the preview)
|
||||
if (useGLB && current.glbReady) current.glbReady.then(() => { if (current) { applyWire(); renderer.render(scene, camera); updateHUD(); } });
|
||||
}
|
||||
|
||||
function updateHUD() {
|
||||
@ -133,7 +139,12 @@ function updateHUD() {
|
||||
`build <b>${current.buildMs.toFixed(1)}</b> ms · path <b>${current.pathOK ? 'ok' : 'FAIL'}</b>${current.carved ? ' (carved)' : ''}\n` +
|
||||
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
|
||||
`places ${current.places.length} · counter ${current.recipe.counterPos}` +
|
||||
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '');
|
||||
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '') +
|
||||
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
|
||||
}
|
||||
function glbCount() {
|
||||
let n = 0; if (current) current.group.traverse(o => { if (o.userData && o.userData.glbUpgrade) n++; });
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── debug overlays ───────────────────────────────────────────────────────────
|
||||
@ -200,6 +211,7 @@ $('seed').onchange = rebuild; $('lot').onchange = rebuild;
|
||||
$('tgWire').onchange = e => { wire = e.target.checked; applyWire(); };
|
||||
$('tgGrid').onchange = e => { showGrid = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
|
||||
$('tgPath').onchange = e => { showPath = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
|
||||
$('tgGLB').onchange = e => { useGLB = e.target.checked; rebuild(); };
|
||||
|
||||
const keys = {};
|
||||
addEventListener('keydown', e => {
|
||||
|
||||
@ -6,23 +6,36 @@
|
||||
//
|
||||
// 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 footprint-fitted to the primitive it replaces, so occupancy/paths
|
||||
// already computed by layout.js stay valid.
|
||||
// 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)
|
||||
// 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',
|
||||
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',
|
||||
counter: 'counter',
|
||||
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') {
|
||||
@ -32,35 +45,45 @@ export function manifestReady() { return _manifestP && typeof _manifestP.then ==
|
||||
|
||||
// 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;
|
||||
if (!manifest || !manifest.fittings) return Promise.resolve();
|
||||
const id = KIND_TO_GLB[kind];
|
||||
const entry = id && manifest.fittings[id];
|
||||
if (!entry || !entry.file) return;
|
||||
if (!entry || !entry.file) return Promise.resolve();
|
||||
|
||||
loadGLB(`depot:${entry.file}`).then(gltf => {
|
||||
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
|
||||
// fit the GLB to the primitive's footprint (GLB convention: origin at base, facing −Z, metres)
|
||||
// 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, targetW = Math.max(want.w, want.d);
|
||||
const want = fitting.footprint;
|
||||
const glbW = Math.max(size.x, size.z) || 1;
|
||||
const s = targetW / glbW;
|
||||
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
|
||||
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 };
|
||||
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;
|
||||
for (const p of placed) if (!p.removed) upgradeFitting(ctx, p.fitting, p.kind, manifest);
|
||||
if (!manifest) return Promise.resolve();
|
||||
return Promise.all(placed.filter(p => !p.removed).map(p => upgradeFitting(ctx, p.fitting, p.kind, manifest)));
|
||||
}
|
||||
|
||||
@ -82,11 +82,12 @@ export function buildInterior(shop, THREE, opts) {
|
||||
// OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
|
||||
// unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
|
||||
// or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
|
||||
let glbReady = Promise.resolve(); // resolves when GLB upgrades settle (immediately if off)
|
||||
if (opts.useGLB) {
|
||||
const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
|
||||
if (manifest) upgradeRoom(ctx, lay.placed, manifest);
|
||||
else preloadManifest(opts.manifestUrl).then(m => {
|
||||
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; upgradeRoom(ctx, lay.placed, m); }
|
||||
if (manifest) glbReady = upgradeRoom(ctx, lay.placed, manifest);
|
||||
else glbReady = preloadManifest(opts.manifestUrl).then(m => {
|
||||
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m); }
|
||||
});
|
||||
}
|
||||
|
||||
@ -105,6 +106,7 @@ export function buildInterior(shop, THREE, opts) {
|
||||
placement: lay.placement,
|
||||
pathOK: lay.pathOK,
|
||||
carved: lay.carved,
|
||||
glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled
|
||||
buildMs,
|
||||
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
|
||||
counts: () => ctx.counts(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user