diff --git a/web/interior_test.html b/web/interior_test.html
index a30296a..37d2793 100644
--- a/web/interior_test.html
+++ b/web/interior_test.html
@@ -171,6 +171,7 @@ function updateHUD() {
`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)}` : '') +
+ `\nbrowse points ${(current.browsePoints || []).length} (Lane D rigs)` +
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
}
function glbCount() {
@@ -223,6 +224,17 @@ function buildPathHelper() {
noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry;
grp.add(post, noseGrp);
}
+ // browse points (R9) — where Lane D stands browser rigs. Cyan post + white nose = facing the goods.
+ for (const bp of (current.browsePoints || [])) {
+ const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
+ new THREE.MeshBasicMaterial({ color: '#2ec8d6', transparent: true, opacity: 0.6 }));
+ post.position.set(bp.x, 0.8, bp.z);
+ const noseGrp = new THREE.Group();
+ const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' }));
+ nose.position.set(0, 0, -0.4); // local −Z = facing (same convention as keeper)
+ noseGrp.add(nose); noseGrp.position.set(bp.x, 1.4, bp.z); noseGrp.rotation.y = bp.ry;
+ grp.add(post, noseGrp);
+ }
pathHelper = grp; scene.add(grp);
}
function applyWire() {
diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js
index 947b342..cbfeb82 100644
--- a/web/js/interiors/interiors.js
+++ b/web/js/interiors/interiors.js
@@ -17,6 +17,8 @@
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
// // stands, in room-local space, facing the customer.
+// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
+// // browser rigs, facing the goods (same frame + ry as counter.stand)
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@@ -119,6 +121,7 @@ export function buildInterior(shop, THREE, opts) {
exits: shell.exits,
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
+ browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,
diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js
index 4e53f7c..b1570ba 100644
--- a/web/js/interiors/layout.js
+++ b/web/js/interiors/layout.js
@@ -124,6 +124,91 @@ function keeperStand(cx, cz, front, W, D) {
return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) };
}
+// ── browse points (round 9): where Lane D stands browser rigs ─────────────────────────
+// 1–3 seeded floor poses, aisle-adjacent, in front of a browsable fitting, facing the goods. Pure
+// data (no meshes, no draws) — mirrors counter.stand so keepers.js/fleet clones drop straight in.
+// Every point is walkable (occ-free, not reserved), reachable from spawn (same flood-fill as
+// door→counter), ≥MIN_SEP from each other AND the keeper stand. Deterministic per shop.seed.
+const MIN_SEP = 0.6; // min spacing between rigs (m)
+const STAND_GAP = 0.55; // how far in front of a fitting a browser stands (m)
+function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
+ // reachable floor cells from spawn (adjust off an occupied spawn cell like reaches() does)
+ let start = spawnCell;
+ if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
+ const reach = floodFill(grid, start);
+
+ // browsable = surviving floor fittings (not the counter, not wall-mounted). Prefer ones that actually
+ // carry interactable stock (bins/cases have `places`) so browsers cluster at the goods, then bigger
+ // fittings, then a seeded tiebreak — all pure functions of seed/geometry (determinism holds).
+ const cand = placed
+ .filter(p => !p.removed && !p.noStamp && p.kind !== 'counter')
+ .map(p => ({ p, jitter: r() })) // draw seeded keys up front so ordering is seed-stable
+ .sort((a, b) => {
+ const ap = (a.p.fitting.places || []).length ? 1 : 0, bp = (b.p.fitting.places || []).length ? 1 : 0;
+ if (ap !== bp) return bp - ap;
+ const aa = a.p.hw * a.p.hd, ba = b.p.hw * b.p.hd;
+ if (Math.abs(aa - ba) > 0.05) return ba - aa;
+ return a.jitter - b.jitter;
+ })
+ .map(o => o.p);
+
+ const pts = [];
+ const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
+ // Is (x,z) a valid, unclaimed stand? (walkable free floor · reachable · clear of walls/keeper/other rigs)
+ const validStand = (x, z) => {
+ const [cx, cz] = grid.cellOf(x, z);
+ if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
+ const idx = cz * grid.cols + cx;
+ if (grid.occ[idx] !== 0 || grid.reserved[idx]) return false;
+ if (!reach.has(idx)) return false;
+ if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) return false;
+ if (near(x, z, pts, MIN_SEP)) return false;
+ return true;
+ };
+ const push = (x, z, p) => {
+ const dx = p.x - x, dz = p.z - z; // stand → fitting; face it (counter.stand ry convention)
+ pts.push({ x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)), atKind: p.kind, slotIndex: pts.length });
+ };
+ // Stand candidates around a fitting: 4 sides at two gaps + 4 diagonals, tried toward-room-centre
+ // first (the open aisle) so a browser faces OUT of a wall-hugging shelf, never into the wall. Richer
+ // than a single ring so a valid cell survives even in a tight/pokey room where one side is blocked.
+ const standsAround = (p) => {
+ const tX = p.x > 0 ? -1 : 1, gW = p.hw + STAND_GAP, gW2 = p.hw + STAND_GAP + 0.35, gD = p.hd + STAND_GAP;
+ return [
+ { x: p.x + tX * gW, z: p.z }, { x: p.x, z: p.z + gD },
+ { x: p.x + tX * gW, z: p.z + 0.5 }, { x: p.x + tX * gW, z: p.z - 0.5 },
+ { x: p.x + tX * gW2, z: p.z }, { x: p.x, z: p.z - gD },
+ { x: p.x - tX * gW, z: p.z }, { x: p.x - tX * gW, z: p.z + 0.5 },
+ ];
+ };
+ for (const p of cand) {
+ if (pts.length >= 3) break;
+ for (const s of standsAround(p)) {
+ if (!validStand(s.x, s.z)) continue;
+ push(s.x, s.z, p); break; // one point per fitting
+ }
+ }
+ // Fallback: a room with browsable goods but no point yet (all fitting sides boxed in — tight pokey/
+ // mismatched archetypes) still deserves ≥1 browser. Scan the reachable free floor for the cell nearest
+ // a browsable fitting and stand there, facing that fitting. Deterministic (grid + cand order are seeded).
+ if (pts.length === 0 && cand.length) {
+ let best = null;
+ for (const idx of reach) {
+ if (grid.occ[idx] !== 0 || grid.reserved[idx]) continue;
+ const cx = idx % grid.cols, cz = (idx / grid.cols) | 0;
+ const x = (cx + 0.5) * grid.cw - grid.W / 2, z = (cz + 0.5) * grid.cd - grid.D / 2;
+ if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) continue;
+ for (const p of cand) {
+ const d = Math.hypot(p.x - x, p.z - z);
+ if (d < 0.5) continue; // not on top of the fitting
+ if (!best || d < best.d) best = { x, z, p, d };
+ }
+ }
+ if (best) push(best.x, best.z, best.p);
+ }
+ return pts;
+}
+
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
function makeWallSlots(W, D, r) {
const slots = [];
@@ -240,6 +325,11 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
+ // 6) BROWSE POINTS (round 9) — 1–3 seeded floor poses for Lane D's browser rigs, in front of the
+ // goods, reachable + non-blocking. Pure data (no geometry). Computed AFTER the path guarantee so
+ // every point sits on a survivor's open side and on a cell reachable from the door.
+ const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
+
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
@@ -249,7 +339,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
return {
placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved,
- spawnCell, targetCell, placement,
+ spawnCell, targetCell, placement, browsePoints,
counter: {
mesh: counter.fitting.group,
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself