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>
16 KiB
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 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.
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 usesdepot:<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 manifestfacingconvention ("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 + aGLB props: N upgradedHUD 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.
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).poseis the bench;standis 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 viainterior.group).ryuses the rig-front = local −Z convention keepers.js expects. - The same
{x,z,ry}also rides the counter interactable:place.userData.keeperStand— for theplaces-iteration path Lane D/F described. Deep-equal toroom.counter.stand.
Lane D/F wiring (drop-in for §3.2/§3.3):
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 | public API — buildInterior(shop, THREE, opts) |
| theme.js | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
| shell.js | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
| 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 | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, guaranteed door→counter flood-fill path |
| 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 | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free disposeAll() |
| 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 | standalone test page (below) |
Public API (the contract Lane B/F build against)
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 toopshop. - 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.jsported (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
buildShopported: 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— itsSHOP_TYPE_IDSmatch my 9 recipe keys exactly. I kept the interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail), withmergeRegistry()as the one-time override seam for Lane F. - Lane E
web/assets/manifest.json— itsfittingssection (9 depot GLBs with footprints) is now wired throughglb.js(opt-inuseGLB, 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— itskeepers.spawn(group,{x,z,ry,shopId,type})now has a clean source:room.counter.stand(also onplace.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 —
stockAdapterhook 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) +useGLBflipped 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).