PROCITY/web/interior_test.html
m3ultra 0e9d3fb0f2 Lane C: on-demand seeded shop interiors (library + test page)
Every shop door opens into a unique, believable, seeded interior — generated
in ~4ms, byte-identical every revisit (shop.seed), themed by shop.type.

- interiors.js: buildInterior(shop, THREE, opts) public API — pure fn of shop,
  returns {group, spawn, exits, places, dims, placement, dispose()}.
- theme.js: 9 CITY_SPEC type recipes (archetype/wallpaper/floor bias, clutter,
  counter pos, fittings mix, stock kind, signage) + type aliasing + one-time
  mergeRegistry() override seam for Lane F. Standalone (no hard registry dep).
- shell.js: room shell from lot x archetype (cosy/gallery/wide/hall/pokey),
  glazed shopfront + street backdrop, blocked back doorway, interior lighting.
- fittings.js: parametric kit ported from 90sDJsim + extended (bins, crates,
  4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner
  racks, armchair, escalator, pegboard, barred window, returns slot, art).
- layout.js: per-archetype zones, thriftgod shuffled wall-slot system,
  occupancy grid, guaranteed door->counter flood-fill path (pull/carve).
- stock.js: v1 visual stock (pooled canvas sleeves/spines/boxes/garments/snacks
  with price stickers) + stockAdapter hook for BaseGod content later.
- context.js: seed sub-streams + shared-geometry cache + leak-free disposeAll().
- glb.js: optional GLB hero-prop upgrade via Lane E manifest (off by default,
  primitive fallback).
- interior_test.html: standalone page — seed/type/archetype, first-person walk,
  wireframe/occupancy/path debug, 50-room soak (perf + leak + determinism).

Acceptance (verified): same seed -> identical placement (0/810 mismatches);
9 types x 5 archetypes render sensibly (docs/shots/laneC grid); build <50ms
(steady ~4ms, soak worst 8ms); leak-free dispose (geo/tex delta 0); door->counter
path always exists (0 fails, 0 carves); runs with zero assets and zero network.
Adversarial multi-agent review: 5 findings, all fixed and re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:08:12 +10:00

285 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane C — Interior Test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; font: 13px/1.4 -apple-system, system-ui, sans-serif; background: #14110c; color: #e8e0d0; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#ui {
position: fixed; top: 10px; left: 10px; z-index: 10; width: 268px;
background: rgba(24,20,14,0.92); border: 1px solid #4a4030; border-radius: 8px; padding: 12px;
backdrop-filter: blur(6px); box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
#ui h1 { margin: 0 0 8px; font-size: 14px; letter-spacing: .5px; color: #ffd75e; }
#ui .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }
#ui label { flex: 0 0 66px; color: #b7ab90; }
#ui select, #ui input { flex: 1; background: #211c14; color: #e8e0d0; border: 1px solid #4a4030; border-radius: 5px; padding: 4px 6px; font: inherit; }
#ui button { flex: 1; background: #3a3120; color: #ffe9a8; border: 1px solid #6a5a38; border-radius: 5px; padding: 6px; font: inherit; cursor: pointer; }
#ui button:hover { background: #4a3f28; }
#ui .btnrow { display: flex; gap: 6px; margin-top: 6px; }
#ui .toggles { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: #b7ab90; }
#ui .toggles label { flex: none; }
#hud { margin-top: 10px; padding-top: 8px; border-top: 1px solid #3a3226; font-size: 12px; color: #c9bfa4; white-space: pre-line; }
#hud b { color: #ffd75e; font-weight: 600; }
#soak { margin-top: 8px; font-size: 11.5px; color: #9fd6a0; white-space: pre-line; min-height: 14px; }
#soak.bad { color: #f3a0a0; }
#hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 10;
background: rgba(24,20,14,0.85); border: 1px solid #4a4030; border-radius: 6px; padding: 6px 12px; color: #b7ab90; }
#hint b { color: #ffd75e; }
</style>
</head>
<body>
<div id="app"></div>
<div id="ui">
<h1>PROCITY · Lane C</h1>
<div class="row"><label>Seed</label><input id="seed" type="number" value="1990" /></div>
<div class="row"><label>Type</label><select id="type"></select></div>
<div class="row"><label>Archetype</label><select id="arch"></select></div>
<div class="row"><label>Lot w×d</label><input id="lot" type="text" value="auto" /></div>
<div class="btnrow">
<button id="reroll">re-roll ⟳</button>
<button id="prev">◀ seed</button>
<button id="next">seed ▶</button>
</div>
<div class="toggles">
<label><input type="checkbox" id="tgWire"> wireframe</label>
<label><input type="checkbox" id="tgGrid"> occupancy</label>
<label><input type="checkbox" id="tgPath"> path</label>
</div>
<div class="btnrow"><button id="soakBtn">▶ 50-room soak test</button></div>
<div id="soak"></div>
<div id="hud"></div>
</div>
<div id="hint">Click to walk · <b>WASD</b> move · <b>mouse</b> look · <b>Esc</b> release · <b>G</b> grid · <b>F</b> wireframe</div>
<script type="importmap">
{
"imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS } from './js/interiors/interiors.js';
// ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 200);
const controls = new PointerLockControls(camera, renderer.domElement);
renderer.domElement.addEventListener('click', () => controls.lock());
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ── UI setup ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const typeSel = $('type'), archSel = $('arch');
for (const t of SHOP_TYPES) { const o = document.createElement('option'); o.value = t; o.textContent = t; typeSel.appendChild(o); }
archSel.appendChild(new Option('auto', 'auto'));
for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
let current = null; // the live interior
let gridHelper = null, pathHelper = null;
let wire = false, showGrid = false, showPath = false;
function parseLot(v) {
const m = String(v).match(/(\d+(?:\.\d+)?)\s*[x×,\s]\s*(\d+(?:\.\d+)?)/);
return m ? { w: +m[1], d: +m[2] } : null;
}
function rebuild() {
if (current) current.dispose();
clearDebug();
const seed = parseInt($('seed').value, 10) >>> 0;
const type = typeSel.value;
const arch = archSel.value === 'auto' ? undefined : archSel.value;
const lot = parseLot($('lot').value);
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
current = buildInterior(shop, THREE, { archetype: arch });
scene.add(current.group);
// place the player at the spawn
camera.position.set(current.spawn.x, 1.6, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0);
applyWire();
if (showGrid) buildGridHelper();
if (showPath) buildPathHelper();
updateHUD();
}
function updateHUD() {
const d = current.dims, c = current.counts();
$('hud').innerHTML =
`<b>${current.recipe.label}</b> · ${d.archetype}\n` +
`room <b>${d.W.toFixed(1)}×${d.D.toFixed(1)}×${d.H.toFixed(1)}</b> m\n` +
`build <b>${current.buildMs.toFixed(1)}</b> ms · path <b>${current.pathOK ? 'ok' : 'FAIL'}</b>${current.carved ? ' (carved)' : ''}\n` +
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
`places ${current.places.length} · counter ${current.recipe.counterPos}`;
}
// ── debug overlays ───────────────────────────────────────────────────────────
function clearDebug() {
for (const h of [gridHelper, pathHelper]) if (h) { scene.remove(h); h.traverse(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); }
gridHelper = pathHelper = null;
}
function buildGridHelper() {
const g = current._debug.grid;
const grp = new THREE.Group();
const geo = new THREE.PlaneGeometry(g.cw * 0.92, g.cd * 0.92);
const matOcc = new THREE.MeshBasicMaterial({ color: '#d6402e', transparent: true, opacity: 0.4, side: THREE.DoubleSide });
const matWall = new THREE.MeshBasicMaterial({ color: '#2e6ad6', transparent: true, opacity: 0.25, side: THREE.DoubleSide });
for (let cz = 0; cz < g.rows; cz++) for (let cx = 0; cx < g.cols; cx++) {
const v = g.occ[cz * g.cols + cx];
if (v === 0) continue;
const m = new THREE.Mesh(geo, v === 2 ? matWall : matOcc);
m.rotation.x = -Math.PI / 2;
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.03, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
}
gridHelper = grp; scene.add(grp);
}
function buildPathHelper() {
const g = current._debug.grid;
const start = current._debug.spawnCell, target = current._debug.targetCell;
const grp = new THREE.Group();
const mk = (idx, col) => {
const cx = idx % g.cols, cz = (idx / g.cols) | 0;
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.05, 12), new THREE.MeshBasicMaterial({ color: col }));
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.06, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
};
mk(start, '#3ad66a'); mk(target, '#ffd75e');
pathHelper = grp; scene.add(grp);
}
function applyWire() {
current.group.traverse(o => {
if (!o.isMesh) return;
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) if (m && 'wireframe' in m) m.wireframe = wire;
});
}
// ── controls / events ─────────────────────────────────────────────────────────
$('reroll').onclick = () => { $('seed').value = (Math.floor(Math.random() * 1e6)); rebuild(); };
$('next').onclick = () => { $('seed').value = (+$('seed').value + 1); rebuild(); };
$('prev').onclick = () => { $('seed').value = Math.max(0, +$('seed').value - 1); rebuild(); };
typeSel.onchange = rebuild; archSel.onchange = rebuild;
$('seed').onchange = rebuild; $('lot').onchange = rebuild;
$('tgWire').onchange = e => { wire = e.target.checked; applyWire(); };
$('tgGrid').onchange = e => { showGrid = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
$('tgPath').onchange = e => { showPath = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
const keys = {};
addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyG') { $('tgGrid').checked = !showGrid; $('tgGrid').onchange({ target: $('tgGrid') }); }
if (e.code === 'KeyF') { $('tgWire').checked = !wire; $('tgWire').onchange({ target: $('tgWire') }); }
});
addEventListener('keyup', e => { keys[e.code] = false; });
// ── first-person movement with occupancy collision + room clamp ────────────────
const RADIUS = 0.28, SPEED = 3.2;
function walkable(x, z) {
const g = current._debug.grid;
if (Math.abs(x) > g.W / 2 - RADIUS || Math.abs(z) > g.D / 2 - RADIUS) return false;
for (const [ox, oz] of [[RADIUS, 0], [-RADIUS, 0], [0, RADIUS], [0, -RADIUS]]) {
const [cx, cz] = g.cellOf(x + ox, z + oz);
if (g.occ[cz * g.cols + cx] === 1) return false; // fitting blocks; walls handled by bounds
}
return true;
}
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
function move(dt) {
if (!controls.isLocked || !current) return;
let f = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
let s = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
if (!f && !s) return;
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
right.crossVectors(fwd, up).normalize();
const dx = (fwd.x * f + right.x * s), dz = (fwd.z * f + right.z * s);
const len = Math.hypot(dx, dz) || 1;
const step = SPEED * dt;
const nx = dx / len * step, nz = dz / len * step;
const p = camera.position;
if (walkable(p.x + nx, p.z)) p.x += nx; // per-axis → slide along walls
if (walkable(p.x, p.z + nz)) p.z += nz;
}
// ── soak test: build+dispose 50 rooms, assert leak-free + <50ms + deterministic ──
async function soak(N = 50) {
const el = $('soak'); el.classList.remove('bad'); el.textContent = 'running soak…';
if (current) { current.dispose(); current = null; clearDebug(); }
await new Promise(r => requestAnimationFrame(r));
const sampleShop = (i) => ({ id: 'soak' + i, type: SHOP_TYPES[i % SHOP_TYPES.length],
name: 'SOAK ' + i, seed: (1000 + i * 2654435761) >>> 0, storeys: 1 + (i % 3 === 0 ? 1 : 0) });
// warmup so shared file textures are in cache before we take the baseline
const warm = buildInterior(sampleShop(0), THREE); scene.add(warm.group); renderer.render(scene, camera);
scene.remove(warm.group); warm.dispose();
await new Promise(r => requestAnimationFrame(r));
const baseGeo = renderer.info.memory.geometries, baseTex = renderer.info.memory.textures;
let total = 0, worst = 0, detFail = 0;
for (let i = 0; i < N; i++) {
const shop = sampleShop(i);
const t0 = performance.now();
const r = buildInterior(shop, THREE);
const ms = performance.now() - t0; total += ms; worst = Math.max(worst, ms);
scene.add(r.group); renderer.render(scene, camera); // force GPU upload so info.memory is real
// determinism: same seed twice ⇒ identical placement list
const r2 = buildInterior(shop, THREE);
if (JSON.stringify(r.placement) !== JSON.stringify(r2.placement)) detFail++;
r2.dispose();
scene.remove(r.group); r.dispose();
}
renderer.render(scene, camera);
const leakGeo = renderer.info.memory.geometries - baseGeo;
const leakTex = renderer.info.memory.textures - baseTex;
const avg = total / N;
const pass = leakGeo <= 0 && leakTex <= 0 && worst < 50 && detFail === 0;
el.classList.toggle('bad', !pass);
el.textContent =
`${pass ? '✅ PASS' : '❌ CHECK'} · ${N} rooms\n` +
`avg ${avg.toFixed(1)}ms · worst ${worst.toFixed(1)}ms (budget 50)\n` +
`leak: geo ${leakGeo} · tex ${leakTex} (want ≤0)\n` +
`determinism: ${detFail === 0 ? 'identical ✓' : detFail + ' MISMATCH'}`;
rebuild();
}
$('soakBtn').onclick = () => soak(50);
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function frame() {
const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
move(dt);
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
// expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, rebuild, get current() { return current; }, scene, camera, renderer };
rebuild();
frame();
</script>
</body>
</html>