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
400 lines
19 KiB
HTML
400 lines
19 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; }
|
||
#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>
|