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>
311 lines
16 KiB
HTML
311 lines
16 KiB
HTML
<!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>
|
||
<label><input type="checkbox" id="tgGLB"> GLB props</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, useGLB = false;
|
||
let glbManifest = null; // Lane E manifest + localBase → validate GLB props vs locally-served copies
|
||
fetch('assets/manifest.json').then(r => r.ok ? r.json() : null)
|
||
.then(m => { if (m) { m.localBase = 'assets/_local_glb/'; glbManifest = m; } }).catch(() => {});
|
||
|
||
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, useGLB, manifest: useGLB ? glbManifest : undefined });
|
||
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();
|
||
// GLB props load async — re-render + refresh HUD once they settle (rAF is throttled in the preview)
|
||
if (useGLB && current.glbReady) current.glbReady.then(() => { if (current) { applyWire(); renderer.render(scene, camera); 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}` +
|
||
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '') +
|
||
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
|
||
}
|
||
function glbCount() {
|
||
let n = 0; if (current) current.group.traverse(o => { if (o.userData && o.userData.glbUpgrade) n++; });
|
||
return n;
|
||
}
|
||
|
||
// ── 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');
|
||
// keeper stand pose (Lane D spawns a shopkeeper here) — magenta post + a white nose showing the
|
||
// facing (local −Z = rig front, the convention keepers.js uses).
|
||
const ks = current.counter && current.counter.stand;
|
||
if (ks) {
|
||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
|
||
new THREE.MeshBasicMaterial({ color: '#d63ad6', transparent: true, opacity: 0.6 }));
|
||
post.position.set(ks.x, 0.8, ks.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 = the way the keeper faces
|
||
noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry;
|
||
grp.add(post, noseGrp);
|
||
}
|
||
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(); };
|
||
$('tgGLB').onchange = e => { useGLB = e.target.checked; rebuild(); };
|
||
|
||
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>
|