Files
VoxelForge/Standalone/Tools/webviewer/index.html
T
Fr0zkaandClaude Opus 5.5 94dc942f5e tools: local web viewer for generated meshes (W1)
Standalone/Tools/webviewer: stdlib Python server bound to 127.0.0.1 (mesh list API, .glb
serving with path checks), .vfmesh (VFMSH001/002) -> GLB converter (Z-up -> Y-up, normals,
tile/triangle metadata), and a three.js 0.160.0 page: WASD/mouse + touch flight, speed, fog,
wireframe/normal colours, stats, mesh picker, A/B toggle. Three sample GLBs. Server smoke-tested
on localhost (index, mesh list, GLB, traversal rejected); in-browser rendering not yet checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
2026-10-28 23:58:36 +01:00

357 lines
17 KiB
HTML

<!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; }
h1 { margin: 0 0 8px; font-size: 17px; letter-spacing: .02em; }
.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; }
#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: 10px; } }
@media (max-width: 540px) { #panel { width: min(300px, calc(100vw - 16px)); }
#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">
<h1>VoxelForge World Viewer</h1>
<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">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 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) { status.textContent = message; }
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}…`);
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)}%`);
}, (error) => {
console.error(error);
setStatus(`Could not load ${name}; check the server and mesh file.`);
});
}
async function populateMeshes() {
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('Convert a .vfmesh file into the configured mesh folder.');
return;
}
main.value = meshes[0].name;
loadMesh(main.value, 'A');
setStatus(`${meshes.length} mesh${meshes.length === 1 ? '' : 'es'} available`);
}
$('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 contact the local viewer server.');
});
updateCameraRotation();
requestAnimationFrame(animate);
</script>
</body>
</html>