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>
This commit is contained in:
m3ultra 2026-07-14 18:44:09 +10:00
parent 518678d4e4
commit 34926324bd
8 changed files with 180 additions and 16 deletions

View File

@ -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 (round 3) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-14 (round 4) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-14 (round 4) — hero props into rooms
Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14
fittings), sweep/soak with them on, screenshot each, delete the stale `_local_glb/`, measure a hero
room. **All done.** Full report for Lane E: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md);
contact sheet [docs/shots/laneC/glb_heroprops_r4.jpg](docs/shots/laneC/glb_heroprops_r4.jpg).
- **New mappings** (`glb.js`): `arcade_cabinet`→new `arcadeCabinet` primitive (video focal prop),
`listening_booth`→`listeningCorner` (record), `drinks_fridge`→`fridge` (milkbar). All load, `glb_law`
compliant, yaw 0 (no correction). A new **counter-top attach** (`attachCounterTop`) puts `cash_register`
on every counter's till slot (hides the primitive till) and `milkshake_mixer` on the milk-bar benchtop —
the counter primitive + keeper stand are untouched.
- **Focal-prop reliability fix**: the door-counter path loop was pulling priority-4 focal props (arcade
appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first →
**24/24 appearance**, still 0 carves / 0 path-fails.
- **Validation**: primitive sweep 810 builds `{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 confirmed in-room.
- **Room tris measured** (task 4): milk-bar GLB-on ≈ **468k tris** (over budget) driven by `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 / the street budget is unaffected).
- **Cleanup**: deleted `web/assets/_local_glb/` (R3 validation copy); test page now loads GLBs via the
live `depot:` path (`?localdepot=1` still works for offline). `localBase` hook kept in `glb.js`.
- **Standby (C task 5)**: when Lane E re-exports `record_crate` (broken) + a ~2 m counter-with-till, I
re-map both. Not landed at commit time.
---

View File

@ -1,4 +1,59 @@
# LANE C — GLB validation report (round 3) → for Lane E + Fable
# LANE C — GLB validation report → for Lane E + Fable
## ROUND 4 update (2026-07-14) — the 4 new hero props + cash_register
Mapped and validated the 5 round-4 hero GLBs (manifest now 14 fittings). Loaded via the **live
`depot:` path** (all published, HTTP 200); the R3 `_local_glb/` copy is deleted. Screenshot evidence:
[`docs/shots/laneC/glb_heroprops_r4.jpg`](../shots/laneC/glb_heroprops_r4.jpg).
**All 5 load cleanly** (unlike the still-broken `record_crate`), follow `glb_law` (minY≈0, real metres),
and match their manifest footprints — **no per-kind yaw correction needed (yaw 0)**:
| id / file | native x·y·z (m) | tris | maps to | placement |
|---|---|---|---|---|
| `arcade_cabinet` | 0.75·1.70·0.85 | 25,142 | **new** `arcadeCabinet` primitive (video recipe) | floor, aisle zone, focal |
| `listening_booth` | 0.88·1.60·0.71 | 53,502 | `listeningCorner` (record) | floor, window zone, focal |
| `drinks_fridge` | 0.85·1.60·0.96 | 77,047 | `fridge` (milkbar) | floor, against wall |
| `milkshake_mixer` | 0.38·0.50·0.27 | 73,492 | counter-top (milkbar) | ON counter benchtop (left slot) |
| `cash_register` | 0.42·0.35·0.34 | 5,999 | counter-top (all types) | ON counter benchtop (till slot); hides primitive till |
New mechanism: **counter-top GLB attach** (`glb.js attachCounterTop`) — cash_register replaces the
primitive till on every counter; milkshake_mixer sits on the milk-bar benchtop. The counter primitive
(correct height + keeper stand) is kept; only the till mesh is hidden. Focal props made reliable
(priority 6 + placed first): **24/24 appearance** across seeds, 0 carves, 0 path-fails.
**Sweep + soak with all R4 mappings on**: `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
leakTex 0 · worst build 7.3 ms`. GLB clones share cached geometry (SkeletonUtils.clone) → per-room
dispose is leak-free (baseline taken after a full 9-type warmup).
### ⚠ Interior tris — one room over budget (task 4 report, NOT fixed per integrator decision #2)
Per-room triangles, single interior rendered alone (primitives vs GLB-on), seed 1990:
| room | prims tris | GLB-on tris | GLB-on draws | note |
|---|---|---|---|---|
| pawn | 812 | 7,019 | 161 | fine |
| opshop | 2,476 | 58,230 | 706 | fine |
| video | 5,712 | 61,669 | 461 | arcade 25k dominates — fine |
| book | 6,372 | 102,998 | 1,249 | high **draws** (≈600 primitive spines) |
| record | 2,530 | 115,867 | 337 | listening_booth 53k — fine |
| **milkbar** | 1,662 | **468,106** | 578 | **OVER**`drinks_fridge` (77k) ×up-to-3 + `milkshake_mixer` (73k) |
**→ Lane E bake-pass priority (future, not v1):** `drinks_fridge` (77k) and `milkshake_mixer` (73k) are
grossly over-tessellated for their visual role (a fridge and a bench appliance). Baking these two to
~510k would drop the milk-bar room from ~468k to well under 150k. `cash_register` (6k) and
`arcade_cabinet` (25k) are already reasonable. Interiors render one-at-a-time (Lane F swaps scenes, the
street is not drawn behind them), so this does **not** affect gate 3 (street budget) or the v1 tag — but
it's the #1 candidate for the bake pass. Not blocking; reporting per task 4.
### Still open (Lane E)
- `record_crate` — STILL fails to load (`Cannot read ...'uri'`); record bins/crates stay primitive. Re-export.
- `counter` — still the 4 m till-less asset; interiors keep the primitive counter. A ~2 m counter-with-till
would let me drop cash_register's separate attach and map `counter` directly. When both land I re-map (C task 5).
---
## ROUND 3 report (superseded above where they overlap) → 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -101,9 +101,9 @@ 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, 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(() => {});
let glbManifest = null; // Lane E manifest → GLB props load via the live depot: path (all 22 GLBs published).
fetch('assets/manifest.json').then(r => r.ok ? r.json() : null) // for local-only testing add ?localdepot=1 to the URL
.then(m => { if (m) glbManifest = m; }).catch(() => {});
function parseLot(v) {
const m = String(v).match(/(\d+(?:\.\d+)?)\s*[x×,\s]\s*(\d+(?:\.\d+)?)/);

View File

@ -167,6 +167,27 @@ function vhsAisle(ctx, o = {}, r) {
return { group: g, footprint: { w, d }, height: h, slots, places: [] };
}
// Upright arcade cabinet — the video-store focal prop (GLB `arcade_cabinet` upgrades it).
// Built facing +Z (screen toward the room), footprint/height matched to the manifest so the GLB drops in.
function arcadeCabinet(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 0.72, h = o.h || 1.7, d = o.d || 0.82;
const body = ctx.mat(['#7a2a3a', '#2a3a7a', '#2a6a4a', '#6a3a2a'][(r() * 4) | 0], 0.6);
const black = MAT.black(ctx);
ctx.box(w, h, d * 0.55, body, 0, h / 2, -d * 0.2, g); // tall body (set back)
ctx.box(w, h * 0.5, d * 0.45, body, 0, h * 0.25, d * 0.05, g); // lower front (kickplate)
ctx.box(w + 0.04, 0.2, 0.14, ctx.mat('#f0e070', 0.4, { emissive: new ctx.THREE.Color('#403a10') }), 0, h - 0.1, 0.02, g); // lit marquee
const scr = ctx.box(w - 0.14, 0.52, 0.04, ctx.mat('#0a0e14', 0.25, { emissive: new ctx.THREE.Color('#0c1626') }), 0, h * 0.7, 0.16, g);
scr.rotation.x = 0.22; // slanted dark screen, faces +Z
const cp = ctx.box(w - 0.06, 0.34, 0.05, black, 0, h * 0.46, d * 0.22, g); cp.rotation.x = -1.0; // control panel
ctx.cyl(0.018, 0.13, MAT.chrome(ctx), -0.15, h * 0.5, d * 0.24, g);
ctx.cyl(0.018, 0.13, MAT.chrome(ctx), 0.15, h * 0.5, d * 0.24, g);
[['#e04a3a', -0.02], ['#f0b428', 0.05], ['#4ac04a', 0.12]].forEach(([c, bx]) => ctx.cyl(0.02, 0.03, ctx.mat(c, 0.4), bx, h * 0.51, d * 0.27, g));
ctx.box(0.22, 0.16, 0.02, ctx.mat('#c8a13a', 0.4), 0, h * 0.14, d * 0.28, g); // coin door
g.userData = { kind: 'prop', interactable: true };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [g] };
}
// Spinner rack — rotating wire stand (paperbacks / postcards).
function spinnerRack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
@ -222,12 +243,17 @@ function counter(ctx, o = {}, r) {
const wood = MAT.wood(ctx), dark = MAT.darkwood(ctx);
const body = ctx.box(w, h, d, wood, 0, h / 2, 0, g);
ctx.box(w + 0.1, 0.06, d + 0.1, dark, 0, h, 0, g); // benchtop
ctx.box(0.34, 0.22, 0.28, MAT.black(ctx), w / 2 - 0.35, h + 0.15, 0, g); // till
const till = ctx.box(0.34, 0.22, 0.28, MAT.black(ctx), w / 2 - 0.35, h + 0.15, 0, g); // till
const scr = ctx.plane(0.14, 0.1, ctx.mat('#0a1a0a', 0.4, { emissive: new ctx.THREE.Color('#123') }), g);
scr.position.set(w / 2 - 0.35, h + 0.28, 0.14); scr.rotation.x = -0.5;
till.userData = { tillPrimitive: true }; // glb.js hides these when cash_register lands
scr.userData = { tillPrimitive: true };
body.userData = { kind: 'counter', interactable: true };
g.userData = { kind: 'counter', interactable: true };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g] };
// Counter-top attach points (local space, on the benchtop top surface) for GLB counter-top props
// (cash_register on the till spot; milkshake_mixer on the free left spot). y = benchtop top.
const counterTop = { y: h + 0.03, till: { x: w / 2 - 0.35, z: 0 }, appliance: { x: -w / 2 + 0.4, z: -0.02 } };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g], counterTop };
}
// Glass display case — timber base + tinted glass top, a few "treasures" inside.
@ -352,7 +378,7 @@ function artFrame(ctx, o = {}, r) {
export const FITTINGS = {
recordBin, crate, listeningCorner,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
clothesRack,
trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,

View File

@ -24,6 +24,10 @@ const KIND_TO_GLB = {
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)
@ -34,8 +38,20 @@ 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') {
@ -81,9 +97,48 @@ export function upgradeFitting(ctx, fitting, kind, manifest) {
});
}
// 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)));
// 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);
}

View File

@ -85,9 +85,9 @@ export function buildInterior(shop, THREE, opts) {
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) glbReady = upgradeRoom(ctx, lay.placed, manifest);
if (manifest) glbReady = upgradeRoom(ctx, lay.placed, manifest, recipe.key);
else glbReady = preloadManifest(opts.manifestUrl).then(m => {
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m); }
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m, recipe.key); }
});
}

View File

@ -34,10 +34,10 @@ const RECIPES = {
clutter: 0.85,
counterPos: 'corner',
fittings: [
{ kind: 'listeningCorner', zone: 'window', min: 1, max: 1, priority: 6 }, // focal hero prop — placed first + high priority
{ kind: 'recordBin', zone: 'aisle', min: 2, max: 5, priority: 5 },
{ kind: 'crate', zone: 'centre', min: 1, max: 3, priority: 3 },
{ kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 },
{ kind: 'listeningCorner', zone: 'window', min: 1, max: 1, priority: 4 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'sleeves',
@ -110,6 +110,7 @@ const RECIPES = {
clutter: 0.85,
counterPos: 'door',
fittings: [
{ kind: 'arcadeCabinet', zone: 'aisle', min: 1, max: 1, priority: 6 }, // focal hero prop — placed first + high priority so the door-counter path loop won't pull it
{ kind: 'vhsAisle', zone: 'aisle', min: 2, max: 4, priority: 5 },
{ kind: 'vhsAisle', zone: 'wall', min: 1, max: 2, priority: 4 },
{ kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 },