diff --git a/C-progress.md b/C-progress.md index a8eb114..4f1bce3 100644 --- a/C-progress.md +++ b/C-progress.md @@ -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:`; 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). --- diff --git a/docs/LANES/LANE_C_GLB_VALIDATION.md b/docs/LANES/LANE_C_GLB_VALIDATION.md new file mode 100644 index 0000000..096631b --- /dev/null +++ b/docs/LANES/LANE_C_GLB_VALIDATION.md @@ -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:` 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:` → local, Lane C needs **no change**: +`glb.js` uses `depot:` 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). diff --git a/docs/shots/laneC/glb_validation_grid.jpg b/docs/shots/laneC/glb_validation_grid.jpg new file mode 100644 index 0000000..069402a Binary files /dev/null and b/docs/shots/laneC/glb_validation_grid.jpg differ diff --git a/web/interior_test.html b/web/interior_test.html index cd52732..482cc37 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -50,6 +50,7 @@ +
@@ -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 ${current.buildMs.toFixed(1)} ms · path ${current.pathOK ? 'ok' : 'FAIL'}${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 => { diff --git a/web/js/interiors/glb.js b/web/js/interiors/glb.js index d1a8b03..419f208 100644 --- a/web/js/interiors/glb.js +++ b/web/js/interiors/glb.js @@ -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:` (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))); } diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 8cbd0bd..cd6b6ae 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -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(),