Files
VoxelForge/Standalone/Tools/webviewer/index.html
T
Fr0zkaandClaude Opus 5.5 95cf8de107 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
2026-10-29 00:10:28 +01:00

400 lines
19 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, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#111820">
<title>VoxelForge World Viewer</title>
<style>
:root { color-scheme: dark; font: 14px/1.4 system-ui, sans-serif; }
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #8f9da5; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
#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; }
#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%; }
select, button, input { font: inherit; }
select, button { min-height: 36px; padding: 6px 9px; border: 1px solid #667782;
border-radius: 6px; color: #f5f7f8; background: #202c35; }
button { cursor: pointer; }
button:hover { background: #30424e; }
input[type=range] { width: 48%; accent-color: #9ccbd1; }
#speedValue { min-width: 44px; text-align: right; }
.check { display: flex; align-items: center; gap: 8px; }
.check input { accent-color: #9ccbd1; }
#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; }
.pad { position: absolute; bottom: max(18px, env(safe-area-inset-bottom)); width: 128px; height: 128px;
border: 1px solid #fff7; border-radius: 50%; background: #10182059; pointer-events: auto; touch-action: none; }
#movePad { left: max(18px, env(safe-area-inset-left)); }
#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: 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/"}}
</script>
</head>
<body>
<div id="panel">
<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>
<div class="row"><button id="touchToggle" type="button" aria-pressed="false">Touch pads: Off</button></div>
<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" 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>
<div class="pad" id="lookPad"><div class="knob"></div></div>
</div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x93a2aa);
scene.fog = new THREE.Fog(0x93a2aa, 180, 1800);
const camera = new THREE.PerspectiveCamera(72, innerWidth / innerHeight, 0.08, 25000);
camera.rotation.order = 'YXZ';
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.75));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xe9f1f3, 0x51565a, 1.25));
const sun = new THREE.DirectionalLight(0xfff3dd, 2.1);
sun.position.set(-0.4, 0.85, 0.3);
scene.add(sun);
const loader = new GLTFLoader();
const slots = { A: null, B: null };
let active = 'A';
let yaw = 0, pitch = 0;
let speed = 12;
let pointerLocked = false;
const keys = new Set();
const touchMove = { x: 0, y: 0 };
const touchLook = { x: 0, y: 0 };
let touchPadsVisible = matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
let previousFrame = performance.now();
let lastHudUpdate = 0;
const $ = (id) => document.getElementById(id);
const status = $('status');
const stats = $('stats');
function updateCameraRotation() {
camera.rotation.set(pitch, yaw, 0, 'YXZ');
}
function setStatus(message, state = 'info') {
status.textContent = message;
status.dataset.state = state;
}
function updateHud() {
const item = slots[active];
const p = camera.position;
const count = item ? item.info.triangles.toLocaleString() : '—';
const tiles = item ? item.info.tiles.toLocaleString() : '—';
stats.textContent = `Triangles: ${count}\nTiles: ${tiles}\nCamera: ${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)} m`;
}
function refreshMaterials() {
for (const item of Object.values(slots)) {
if (!item) continue;
item.root.traverse((node) => {
if (!node.isMesh) return;
const geometry = node.geometry;
const normals = geometry.getAttribute('normal');
if ($('normalColors').checked && normals) {
let colors = geometry.getAttribute('color');
if (!colors || colors.count !== normals.count) {
colors = new THREE.BufferAttribute(new Float32Array(normals.count * 3), 3);
geometry.setAttribute('color', colors);
for (let i = 0; i < normals.count; i++) {
colors.setXYZ(i, 0.5 + 0.5 * normals.getX(i), 0.5 + 0.5 * normals.getY(i), 0.5 + 0.5 * normals.getZ(i));
}
}
}
node.material = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.92,
metalness: 0,
vertexColors: $('normalColors').checked,
wireframe: $('wireframe').checked,
side: THREE.DoubleSide
});
});
}
}
function fitActive() {
const item = slots[active];
if (!item) return;
const box = new THREE.Box3().setFromObject(item.root);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.length() * 0.72, 4);
camera.position.set(center.x, center.y + radius * 0.18, center.z + radius);
yaw = 0;
pitch = -0.08;
updateCameraRotation();
updateHud();
}
function setActive(which) {
if (which === 'B' && !slots.B) return;
active = which;
if (slots.A) slots.A.root.visible = active === 'A';
if (slots.B) slots.B.root.visible = active === 'B';
$('switchButton').textContent = active === 'A' ? 'Show B' : 'Show A';
updateHud();
setStatus(`Showing ${active}: ${slots[active]?.name ?? 'no mesh'}`);
}
function loadMesh(name, slot) {
if (!name) {
if (slots[slot]) scene.remove(slots[slot].root);
slots[slot] = null;
if (slot === 'B' && active === 'B') setActive('A');
updateHud();
return;
}
setStatus(`Loading ${name}…`, 'loading');
const start = performance.now();
loader.load(`/meshes/${encodeURIComponent(name)}`, (gltf) => {
if (slots[slot]) scene.remove(slots[slot].root);
const root = gltf.scene;
const extra = gltf.parser.json.scenes?.[0]?.extras?.voxelForge ?? {};
const triangleCount = root.children.reduce((sum, child) => {
let local = 0;
child.traverse((node) => { if (node.isMesh) local += (node.geometry.index?.count ?? 0) / 3; });
return sum + local;
}, 0);
const item = { root, name, info: {
triangles: Number(extra.triangles ?? triangleCount),
tiles: Number(extra.tiles ?? 0),
cameraMeters: extra.cameraMeters,
yawDegrees: Number(extra.yawDegrees ?? 0),
pitchDegrees: Number(extra.pitchDegrees ?? 0),
}};
slots[slot] = item;
root.visible = slot === active;
scene.add(root);
refreshMaterials();
if (slot === 'A') {
if (Array.isArray(item.info.cameraMeters) && item.info.cameraMeters.length === 3) {
camera.position.set(...item.info.cameraMeters);
yaw = THREE.MathUtils.degToRad(item.info.yawDegrees);
pitch = THREE.MathUtils.degToRad(item.info.pitchDegrees);
updateCameraRotation();
} else fitActive();
updateHud();
}
setStatus(`${name} loaded in ${(performance.now() - start).toFixed(1)} ms`);
}, (event) => {
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 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();
const main = $('meshSelect');
const compare = $('compareSelect');
main.replaceChildren();
compare.replaceChildren(new Option('None', ''));
for (const mesh of meshes) {
main.add(new Option(`${mesh.name} (${(mesh.bytes / 1024).toFixed(0)} KiB)`, mesh.name));
compare.add(new Option(mesh.name, mesh.name));
}
if (!meshes.length) {
main.add(new Option('No .glb files found', ''));
setStatus('No GLB meshes found. Export a world into the meshes folder.', 'error');
return;
}
main.value = meshes.some((mesh) => mesh.name === 'sw-arrival.glb')
? 'sw-arrival.glb' : meshes[0].name;
loadMesh(main.value, 'A');
}
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'));
$('fitButton').addEventListener('click', fitActive);
function setTouchPadsVisible(visible) {
touchPadsVisible = visible;
$('touchControls').classList.toggle('visible', visible);
$('touchToggle').textContent = `Touch pads: ${visible ? 'On' : 'Off'}`;
$('touchToggle').setAttribute('aria-pressed', String(visible));
}
$('touchToggle').addEventListener('click', () => setTouchPadsVisible(!touchPadsVisible));
setTouchPadsVisible(touchPadsVisible);
$('wireframe').addEventListener('change', refreshMaterials);
$('normalColors').addEventListener('change', refreshMaterials);
$('speed').addEventListener('input', (event) => {
speed = Number(event.target.value);
$('speedValue').textContent = `${speed} m/s`;
});
renderer.domElement.addEventListener('click', () => {
if (!matchMedia('(pointer: coarse)').matches && renderer.domElement.requestPointerLock) {
renderer.domElement.requestPointerLock();
}
});
document.addEventListener('pointerlockchange', () => { pointerLocked = document.pointerLockElement === renderer.domElement; });
document.addEventListener('mousemove', (event) => {
if (!pointerLocked) return;
yaw -= event.movementX * 0.0022;
pitch -= event.movementY * 0.0022;
pitch = THREE.MathUtils.clamp(pitch, -Math.PI / 2 + 0.02, Math.PI / 2 - 0.02);
updateCameraRotation();
});
document.addEventListener('keydown', (event) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) return;
const key = event.key.toLowerCase();
keys.add(key);
if (key.startsWith('arrow') || [' ', 'w', 'a', 's', 'd', 'c'].includes(key)) event.preventDefault();
if (key === 'b' && !event.repeat && slots.B) setActive(active === 'A' ? 'B' : 'A');
});
document.addEventListener('keyup', (event) => keys.delete(event.key.toLowerCase()));
window.addEventListener('blur', () => keys.clear());
function bindPad(element, state, onLook = false) {
const knob = element.querySelector('.knob');
let activePointer = null;
let lastX = 0, lastY = 0;
const reset = () => {
activePointer = null;
state.x = 0;
state.y = 0;
knob.style.transform = 'translate(0, 0)';
};
element.addEventListener('pointerdown', (event) => {
activePointer = event.pointerId;
lastX = event.clientX;
lastY = event.clientY;
element.setPointerCapture(event.pointerId);
});
element.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return;
if (onLook) {
yaw -= (event.clientX - lastX) * 0.009;
pitch -= (event.clientY - lastY) * 0.009;
pitch = THREE.MathUtils.clamp(pitch, -Math.PI / 2 + 0.02, Math.PI / 2 - 0.02);
updateCameraRotation();
lastX = event.clientX;
lastY = event.clientY;
return;
}
const rect = element.getBoundingClientRect();
const max = Math.min(rect.width, rect.height) * 0.34;
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const length = Math.hypot(dx, dy);
const scale = length > max ? max / length : 1;
state.x = dx * scale / max;
state.y = -dy * scale / max;
knob.style.transform = `translate(${dx * scale}px, ${dy * scale}px)`;
});
element.addEventListener('pointerup', reset);
element.addEventListener('pointercancel', reset);
}
bindPad($('movePad'), touchMove);
bindPad($('lookPad'), touchLook, true);
function animate(now) {
requestAnimationFrame(animate);
const dt = Math.min((now - previousFrame) / 1000, 0.08);
previousFrame = now;
let forward = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0) + touchMove.y;
let right = (keys.has('d') || keys.has('arrowright') ? 1 : 0) - (keys.has('a') || keys.has('arrowleft') ? 1 : 0) + touchMove.x;
let vertical = (keys.has(' ') ? 1 : 0) - (keys.has('c') || keys.has('control') ? 1 : 0);
const mag = Math.hypot(forward, right);
if (mag > 1) { forward /= mag; right /= mag; }
const forwardX = Math.sin(yaw), forwardZ = -Math.cos(yaw);
const rightX = Math.cos(yaw), rightZ = Math.sin(yaw);
camera.position.x += (forwardX * forward + rightX * right) * speed * dt;
camera.position.z += (forwardZ * forward + rightZ * right) * speed * dt;
camera.position.y += vertical * speed * dt;
if (now - lastHudUpdate > 150) { updateHud(); lastHudUpdate = now; }
renderer.render(scene, camera);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
populateMeshes().catch((error) => {
console.error(error);
setStatus(`Could not load the mesh list: ${error.message || 'server connection failed'}.`, 'error');
});
updateCameraRotation();
requestAnimationFrame(animate);
</script>
</body>
</html>