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:
1 parent
2ef0ca72b2
commit
95cf8de107
5 files changed
+240
-16
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user