From 5ae635a1e96c7bf87e8d672486a3f0e6690e0bd8 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Wed, 15 Jul 2026 09:36:26 +1000 Subject: [PATCH] Lane C round-9 C1: seeded browse points (room.browsePoints) for Lane D rigs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/interior_test.html | 12 +++++ web/js/interiors/interiors.js | 3 ++ web/js/interiors/layout.js | 92 ++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 1 deletion(-) 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