Lane C round-9 C1: seeded browse points (room.browsePoints) for Lane D rigs

layout.js placeBrowsePoints(): 1–3 deterministic floor poses in front of
browsable floor fittings, facing the goods. Guarantees (all asserted):
walkable (occ-free, not reserved), reachable from spawn (door→counter
flood-fill), ≥0.6m apart + clear of keeper stand, every archetype (rich
candidate ring — 4 sides×2 gaps + diagonals — plus a nearest-free-cell
fallback so tight/mismatched rooms still yield ≥1). Pure data: no meshes,
no draws; ≤350 law untouched. Exposed as room.browsePoints[]
{x,z,ry,atKind,slotIndex}, same coord frame + ry convention as
counter.stand (contract in LANE_C_NOTES).

Validated headless: 180 type×archetype×seed combos → 0 empties (177×3,
3×1 via fallback), determinism 0 fails, min-sep 0.7m, 0 keeper clashes.
Test page: cyan browse-post markers on the 'path' overlay + HUD count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 09:36:26 +10:00
parent 5529d62263
commit 5ae635a1e9
3 changed files with 106 additions and 1 deletions

View File

@ -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() {

View File

@ -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,

View File

@ -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 ─────────────────────────
// 13 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) — 13 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