tools: web viewer shows the owner's world; collapsible phone menu (W2)

export_world.py exports the owner four-strate world (LOD0-2 around each arrival + one FI
passage view) through the headless mesh exporter and converts to GLB (16-45 MB, 0.6-1.7M
triangles each); GLBs are git-ignored and regenerated by the script. Menu collapses to a
button (collapsed by default on phones), smaller phone controls, loading progress and errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-10-29 00:10:28 +01:00
1 parent 2ef0ca72b2
commit 95cf8de107
5 files changed
+240 -16

No files matched your search

+57 -14
View File
@@ -13,7 +13,14 @@
#panel { position: fixed; z-index: 2; top: 12px; left: 12px; width: min(340px, calc(100vw - 24px));
max-height: calc(100dvh - 24px); overflow: auto; padding: 14px; border: 1px solid #ffffff2b;
border-radius: 12px; background: #111820e8; color: #edf2f4; box-shadow: 0 5px 24px #0005; }
h1 { margin: 0 0 8px; font-size: 17px; letter-spacing: .02em; }
#panel.collapsed { width: 44px; height: 44px; min-height: 44px; padding: 4px; overflow: hidden; }
#panel.collapsed #panelContent { display: none; }
#panelHeader { display: flex; align-items: center; gap: 8px; }
h1 { flex: 1; margin: 0 0 8px; font-size: 17px; letter-spacing: .02em; }
#panelToggle { flex: none; width: 34px; min-height: 34px; padding: 0; font-size: 18px; line-height: 1; }
#panel.collapsed #panelHeader { justify-content: center; }
#panel.collapsed h1 { display: none; }
#panel.collapsed #panelToggle { width: 34px; height: 34px; }
.row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
.row label { flex: 1; }
.row select { min-width: 0; width: 58%; }
@@ -29,6 +36,8 @@
#stats { margin: 10px 0 0; padding-top: 9px; border-top: 1px solid #ffffff24;
color: #c4d0d5; font: 12px/1.55 ui-monospace, monospace; white-space: pre-wrap; }
#status { min-height: 19px; color: #9fcbd0; font-size: 12px; }
#status[data-state="loading"] { color: #d5c88f; }
#status[data-state="error"] { color: #ffaaa3; }
#help { margin-top: 8px; color: #aab7bd; font-size: 11px; }
#touchControls { display: none; position: fixed; z-index: 1; inset: 0; pointer-events: none; }
#touchControls.visible { display: block; }
@@ -38,9 +47,20 @@
#lookPad { right: max(18px, env(safe-area-inset-right)); width: 34vw; min-width: 128px; max-width: 220px; }
.knob { position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px;
border: 1px solid #fff9; border-radius: 50%; background: #a5d5dc99; pointer-events: none; }
@media (pointer: coarse) { #panel { top: 8px; left: 8px; padding: 10px; } }
@media (max-width: 540px) { #panel { width: min(300px, calc(100vw - 16px)); }
#panel .compact { display: none; } }
@media (pointer: coarse) { #panel { top: 8px; left: 8px; padding: 9px; } }
@media (max-width: 540px) {
:root { font-size: 12px; }
#panel { width: min(286px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); padding: 8px; }
#panel.collapsed { width: 44px; height: 44px; padding: 4px; }
h1 { margin-bottom: 5px; font-size: 14px; }
.row { gap: 6px; margin: 5px 0; }
select, button { min-height: 31px; padding: 4px 7px; }
#panelToggle { width: 30px; min-height: 30px; font-size: 16px; }
#stats { margin-top: 7px; padding-top: 6px; font-size: 11px; }
#status { min-height: 17px; font-size: 11px; }
#help { margin-top: 5px; font-size: 10px; }
#panel .compact { display: none; }
}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
@@ -48,7 +68,11 @@
</head>
<body>
<div id="panel">
<h1>VoxelForge World Viewer</h1>
<div id="panelHeader">
<h1>VoxelForge World Viewer</h1>
<button id="panelToggle" type="button" aria-label="Collapse menu" aria-expanded="true" title="Collapse menu">−</button>
</div>
<div id="panelContent">
<div class="row"><label for="meshSelect">Mesh A</label><select id="meshSelect"><option>Loading list…</option></select></div>
<div class="row"><label for="compareSelect">Mesh B</label><select id="compareSelect"><option value="">None</option></select></div>
<div class="row"><button id="switchButton" type="button">Show B</button><button id="fitButton" type="button">Fit view</button></div>
@@ -56,12 +80,13 @@
<div class="row"><label for="speed">Flight speed</label><input id="speed" type="range" min="1" max="80" value="12" step="1"><span id="speedValue">12 m/s</span></div>
<div class="row check"><label for="wireframe">Wireframe</label><input id="wireframe" type="checkbox"></div>
<div class="row check"><label for="normalColors">Normal colours</label><input id="normalColors" type="checkbox"></div>
<div id="status">Loading mesh list…</div>
<div id="status" role="status" aria-live="polite" data-state="loading">Loading mesh list…</div>
<div id="stats">Triangles: —
Tiles: —
Camera: —</div>
<div id="help"><span class="compact">Click the world, then use WASD + mouse. Space/C move vertically; Esc releases the mouse.</span>
<br>B switches A/B. On touch screens, use the left pad to fly and drag the right pad to look.</div>
</div>
</div>
<div id="touchControls" aria-label="Touch flight controls">
<div class="pad" id="movePad"><div class="knob"></div></div>
@@ -106,7 +131,10 @@ Camera: —</div>
function updateCameraRotation() {
camera.rotation.set(pitch, yaw, 0, 'YXZ');
}
function setStatus(message) { status.textContent = message; }
function setStatus(message, state = 'info') {
status.textContent = message;
status.dataset.state = state;
}
function updateHud() {
const item = slots[active];
const p = camera.position;
@@ -176,7 +204,7 @@ Camera: —</div>
updateHud();
return;
}
setStatus(`Loading ${name}…`);
setStatus(`Loading ${name}…`, 'loading');
const start = performance.now();
loader.load(`/meshes/${encodeURIComponent(name)}`, (gltf) => {
if (slots[slot]) scene.remove(slots[slot].root);
@@ -209,14 +237,15 @@ Camera: —</div>
}
setStatus(`${name} loaded in ${(performance.now() - start).toFixed(1)} ms`);
}, (event) => {
if (event.total) setStatus(`Loading ${name}: ${Math.round(event.loaded / event.total * 100)}%`);
if (event.total) setStatus(`Loading ${name}: ${Math.round(event.loaded / event.total * 100)}%`, 'loading');
}, (error) => {
console.error(error);
setStatus(`Could not load ${name}; check the server and mesh file.`);
setStatus(`Could not load ${name}. Check the server connection and mesh file.`, 'error');
});
}
async function populateMeshes() {
setStatus('Loading mesh list…', 'loading');
const response = await fetch('/api/meshes', { cache: 'no-store' });
if (!response.ok) throw new Error(`Mesh list request failed (${response.status})`);
const meshes = await response.json();
@@ -230,14 +259,28 @@ Camera: —</div>
}
if (!meshes.length) {
main.add(new Option('No .glb files found', ''));
setStatus('Convert a .vfmesh file into the configured mesh folder.');
setStatus('No GLB meshes found. Export a world into the meshes folder.', 'error');
return;
}
main.value = meshes[0].name;
main.value = meshes.some((mesh) => mesh.name === 'sw-arrival.glb')
? 'sw-arrival.glb' : meshes[0].name;
loadMesh(main.value, 'A');
setStatus(`${meshes.length} mesh${meshes.length === 1 ? '' : 'es'} available`);
}
function setPanelCollapsed(collapsed) {
const panel = $('panel');
const toggle = $('panelToggle');
panel.classList.toggle('collapsed', collapsed);
toggle.textContent = collapsed ? '☰' : '−';
toggle.setAttribute('aria-label', collapsed ? 'Expand menu' : 'Collapse menu');
toggle.setAttribute('aria-expanded', String(!collapsed));
toggle.title = collapsed ? 'Expand menu' : 'Collapse menu';
}
$('panelToggle').addEventListener('click', () => {
setPanelCollapsed(!$('panel').classList.contains('collapsed'));
});
setPanelCollapsed(matchMedia('(max-width: 540px)').matches);
$('meshSelect').addEventListener('change', (event) => loadMesh(event.target.value, 'A'));
$('compareSelect').addEventListener('change', (event) => loadMesh(event.target.value, 'B'));
$('switchButton').addEventListener('click', () => setActive(active === 'A' ? 'B' : 'A'));
@@ -347,7 +390,7 @@ Camera: —</div>
});
populateMeshes().catch((error) => {
console.error(error);
setStatus('Could not contact the local viewer server.');
setStatus(`Could not load the mesh list: ${error.message || 'server connection failed'}.`, 'error');
});
updateCameraRotation();
requestAnimationFrame(animate);