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>
267 lines
19 KiB
Markdown
267 lines
19 KiB
Markdown
# LANE C — INTERIORS · progress (PROCITY-C)
|
||
|
||
*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 5) · owner: PROCITY-C · reviewer: Fable
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · **F can tag v1.1**
|
||
|
||
Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in `glb.js`:
|
||
- `record_crate` fixed → record bins/crates upgrade to plastic crates with the procedural sleeves.
|
||
- `counter → 'counter_till'` (the real ~1.6 m counter-with-till). Keeper-stand pose unaffected.
|
||
- **Double-till resolved**: cash_register attach dropped when counter_till is live (it has its own till);
|
||
falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
|
||
- Sweep + soak (GLB on): `0 throws / 0 path / 0 det / leak-free / worst 10.4ms`; **0 double tills**.
|
||
Primitive path unchanged (glb.js-only edit). Contact sheet: [glb_v1_1_r5.jpg](docs/shots/laneC/glb_v1_1_r5.jpg).
|
||
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
|
||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). **→ F: C1 committed, v1.1 can be tagged.**
|
||
|
||
Still in flight this round: C2 `?dig=1` crate-riffle (v2, behind flag), C3 record re-measure (after E's bake).
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 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).
|
||
|
||
---
|
||
|
||
## Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3)
|
||
|
||
Lane F's runbook asked (LANE_F_NOTES §8): *"does `places` tag the counter slot with a stable
|
||
`kind:'counter'` so keepers find it?"* **Yes** — the counter interactable has always carried
|
||
`mesh.userData.kind === 'counter'` (+ `interactable:true`). But Lane D's keeper needs more than a
|
||
tag: `keepers.spawn(group,{x,z,ry})` needs *where the shopkeeper stands and which way it faces*
|
||
(keepers.js:28 — "`ry` = the counter's outward facing"). I computed the customer-facing side
|
||
internally but never surfaced it — so Lane D would have read the bench's raw transform and stood a
|
||
keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive):
|
||
|
||
- **New: `room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} }`** (interiors.js). `pose` is the
|
||
bench; **`stand` is the keeper pose** — behind the bench on the service side, facing the customer,
|
||
clamped inside the room, in **room-local space** (the same space Lane D adds the keeper into via
|
||
`interior.group`). `ry` uses the rig-front = local −Z convention keepers.js expects.
|
||
- **The same `{x,z,ry}` also rides the counter interactable**: `place.userData.keeperStand` — for the
|
||
`places`-iteration path Lane D/F described. Deep-equal to `room.counter.stand`.
|
||
|
||
**Lane D/F wiring (drop-in for §3.2/§3.3):**
|
||
```js
|
||
const room = buildInterior(shop, THREE);
|
||
const s = room.counter.stand; // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand
|
||
keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type });
|
||
// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose()
|
||
```
|
||
|
||
Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak):
|
||
`stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) ·
|
||
stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 ·
|
||
pathFail 0 · every room has a counter+stand`. No change to layout/placement of existing rooms.
|
||
|
||
**Minor known cosmetic (corner counters):** a corner counter sits ~0.55 m off the east wall, so the
|
||
service gap (~0.25 m) is narrower than a person — the keeper stands *at* the till with a small hidden
|
||
overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and
|
||
side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can
|
||
nudge corner counters ~0.2 m off the wall — but that changes those rooms' committed placement +
|
||
screenshots, so it's a decision, not a bug.
|
||
|
||
**Two contract-drift corrections for the F runbook** (§2's Lane-C line was written against an earlier
|
||
draft of my API): (1) there is **no `opts.registry`** — registry override is the one-time global
|
||
`mergeRegistry(registry)` (a per-build registry mutation broke same-seed determinism; removed in the
|
||
review pass). (2) `recipe.counterPos` is a **placement-strategy string** (`door|forward|back|corner`),
|
||
**not** a world position — use `room.counter.stand` for the keeper pose.
|
||
|
||
---
|
||
|
||
## What shipped
|
||
|
||
All files under my lane's ownership (`web/js/interiors/*`, `web/interior_test.html`) — no other lane's
|
||
files touched. Lane A's `core/registry.js` hadn't landed, so the lane is **fully standalone** (its 9
|
||
type recipes live in `theme.js`) with a `mergeRegistry()` hook for Lane F to wire the registry later.
|
||
|
||
| file | role |
|
||
|---|---|
|
||
| [interiors.js](web/js/interiors/interiors.js) | **public API** — `buildInterior(shop, THREE, opts)` |
|
||
| [theme.js](web/js/interiors/theme.js) | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
|
||
| [shell.js](web/js/interiors/shell.js) | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
|
||
| [fittings.js](web/js/interiors/fittings.js) | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
|
||
| [layout.js](web/js/interiors/layout.js) | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, **guaranteed door→counter flood-fill path** |
|
||
| [stock.js](web/js/interiors/stock.js) | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + `stockAdapter` hook for BaseGod data |
|
||
| [context.js](web/js/interiors/context.js) | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free `disposeAll()` |
|
||
| [glb.js](web/js/interiors/glb.js) | **optional** GLB hero-prop upgrade — reads Lane E's `manifest.json`, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
|
||
| [interior_test.html](web/interior_test.html) | standalone test page (below) |
|
||
|
||
## Public API (the contract Lane B/F build against)
|
||
|
||
```js
|
||
import { buildInterior } from './js/interiors/interiors.js';
|
||
import * as THREE from 'three';
|
||
|
||
const room = buildInterior(shop, THREE, opts?);
|
||
scene.add(room.group);
|
||
// … player walks; on hitting room.exits[i], fire procity:exitShop …
|
||
room.dispose(); // frees ALL per-room GPU resources; removes group from parent
|
||
|
||
// room = {
|
||
// group, // THREE.Group, self-contained (lights included)
|
||
// spawn: { x, z, ry }, // enter just inside the door, facing −Z (into the shop)
|
||
// exits: [ { x, z, w, toStreet } ], // door back to street
|
||
// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
|
||
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose
|
||
// dims: { W, D, H, archetype, type },
|
||
// placement, // deterministic summary (deep-equal per seed)
|
||
// pathOK, buildMs, counts(), dispose()
|
||
// }
|
||
```
|
||
|
||
`shop` is tolerant — recognized fields `{ id, type, name, seed, storeys, lot:{w,d} }`:
|
||
- **seed** (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
|
||
- **type** — any CITY_SPEC type (`record opshop toy book video pawn milkbar dept stall`) **or** a
|
||
thriftgod/Overpass alias (`music charity pawnbroker market video_games …`); defaults to `opshop`.
|
||
- **lot** `{w,d}` metres — the room adapts to it (never exceeds it); absent → archetype ranges decide.
|
||
- **storeys** drives ceiling height 3.2–4.5m.
|
||
- `opts`: `{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }`.
|
||
Registry override is a **one-time global** — `import { mergeRegistry } from interiors.js; mergeRegistry(registry)`
|
||
at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).
|
||
|
||
**Coordinate convention** (shared with a future Lane B wiring): `+Z` = door/street side, `−Z` = back,
|
||
origin = room centre, floor `y = 0`. Everything is in the returned `group`'s local space.
|
||
|
||
### For Lane B (door wiring, later)
|
||
`buildInterior` is a pure function — call it on `procity:enterShop`, add `room.group` to the scene,
|
||
teleport the player to `room.spawn`. On `procity:exitShop` (player reaches `room.exits[0]`) call
|
||
`room.dispose()` and restore the street. The street window fake is Lane B's; I own the inside view
|
||
(a bright canvas street backdrop plane is already behind the glazing).
|
||
|
||
### For content phase (BaseGod / GODVERSE)
|
||
Pass `opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null`. Returning a texture
|
||
skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/
|
||
fittings changes needed. `slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }`.
|
||
|
||
## Test page — `web/interior_test.html`
|
||
|
||
Serve: `cd web && python3 -m http.server 8130` → open `/interior_test.html`.
|
||
- seed input · type dropdown (9) · archetype override (auto + 5) · lot `w×d`/auto · re-roll · seed ±
|
||
- first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
|
||
- debug toggles: **wireframe** (F), **occupancy grid** (G), **path** (spawn/counter markers)
|
||
- **▶ 50-room soak test**: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs
|
||
baseline, and determinism (same seed → identical placement)
|
||
|
||
## Acceptance — all met
|
||
|
||
| requirement | result |
|
||
|---|---|
|
||
| Same seed twice → identical room (deep-equal placement) | ✅ 0 mismatches / 810 builds |
|
||
| All 9 types × 5 archetypes render sensibly | ✅ contact sheet `docs/shots/laneC/_grid_9types_5archetypes.jpg` |
|
||
| Build < 50ms/room | ✅ steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops — not a real per-room cost) |
|
||
| Dispose leak-free (info.memory → baseline) | ✅ soak leak: geo 0, tex 0 |
|
||
| Flood-fill door→counter always exists | ✅ 0 path-fails, 0 carves / 810 builds |
|
||
| Runs with zero assets & zero network | ✅ seeded flat-colour fallback under every texture; map only assigned on load |
|
||
|
||
**Full sweep** (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
|
||
`throws 0 · pathFail 0 · determinismFail 0 · carved 0`. Warm perf: all < 50ms (worst 33.2ms).
|
||
**Soak** (50 rooms): `avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0`.
|
||
|
||
## Key decisions & notes for review
|
||
|
||
- **90sDJsim `fittings.js` ported** (it was written to be lifted): `clothesRack`, `shelfUnit`,
|
||
`recordBin`, `table`, `counter`, `pegboard`, garment-silhouette canvas trick. Extended with the
|
||
fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner,
|
||
magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
|
||
- **thriftgod `buildShop` ported**: the 5 archetypes, the shuffled wall-slot system, bins with fanned
|
||
sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
|
||
- **Determinism** via independent seeded sub-streams keyed by a salt string (`ctx.stream('wallpaper')`
|
||
etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
|
||
- **Leak-free** via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
|
||
are freed in `dispose()`; globally-cached file textures (`core/loaders.loadTex`) are shared and not
|
||
disposed (bounded cache). Dispose-before-texture-load race is guarded (`_disposed`).
|
||
- **Perf**: shared-geometry cache (`ctx.boxGeo/planeGeo/cylGeo`) collapses the hundreds of identical
|
||
stock items in a room down to ~80–110 geometries; spine density tuned so a book barn is ~600 meshes.
|
||
- **GLB upgrades**: `web/assets/manifest.json` (Lane E) is read if present, primitive fallback always.
|
||
Not present yet → 100% primitives, which is why the test page runs asset-free.
|
||
|
||
## Integration with sibling lanes (landed in parallel)
|
||
- **Lane A `core/registry.js`** — its `SHOP_TYPE_IDS` match my 9 recipe keys exactly. I kept the
|
||
interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail),
|
||
with `mergeRegistry()` as the one-time override seam for Lane F.
|
||
- **Lane E `web/assets/manifest.json`** — its `fittings` section (9 depot GLBs with footprints) is now
|
||
wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet;
|
||
verified the fallback (unreachable depot → primitives persist, no crash, no leak).
|
||
- **Lane D `citizens/keepers.js`** — its `keepers.spawn(group,{x,z,ry,shopId,type})` now has a clean
|
||
source: `room.counter.stand` (also on `place.userData.keeperStand`). See the *Update (b)* section at
|
||
the top. Verified the pose is on the service side, faces the customer, and is deterministic.
|
||
|
||
## Adversarial review — 5 findings, all fixed
|
||
|
||
Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee /
|
||
three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and
|
||
re-verified (830-build sweep + 50-room soak green after each fix):
|
||
|
||
| # | sev | file | defect | fix |
|
||
|---|---|---|---|---|
|
||
| 1 | med | interiors.js | per-build `opts.registry` permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states | removed per-build option; registry override is now a one-time global `mergeRegistry()` (Lane F setup) |
|
||
| 2 | low | interiors.js | explicit `null` opts threw (`opts = {}` default only covers `undefined`) | normalize `opts = opts \|\| {}` |
|
||
| 3 | low | shell.js | empty `archetypeBias` array → `bias[0][0]` on `[]` throws | fall back to uniform bias when missing **or** empty |
|
||
| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in `places` (dangling after dispose) | collect `places` from survivors at the end, not incrementally at placement |
|
||
| 5 | low | layout.js | `rebuildOcc` re-stamped wall-mounted fittings that `placeAtWall` never stamped → phantom floor obstacles after a blocker-pull | mark wall-mounted `noStamp`; skip in `rebuildOcc` **and** exclude from the removable pool |
|
||
|
||
Verify evidence (post-fix): `nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical ·
|
||
emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0,
|
||
leakTex 0}`. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
|
||
|
||
## Not done (out of scope for v1 / depends on other lanes)
|
||
- Real item data / economy (content phase — `stockAdapter` hook is ready).
|
||
- GLB hero props are **wired** (`glb.js`, opt-in) but not visually validated end-to-end — needs the
|
||
depot GLBs actually uploaded (Lane E) + `useGLB` flipped on by Lane B/F. Primitive path is the tested baseline.
|
||
- Interior-mapping shader for street windows (Lane B stretch goal).
|
||
- Back-room beyond the blocked doorway (v2).
|