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
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-10-28 23:58:36 +01:00
1 parent ef22b03851
commit 94dc942f5e
8 files changed
+719

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# VoxelForge local web viewer
This folder contains a Python standard-library server, a `.vfmesh` to GLB converter, and a Three.js page for viewing exported VoxelForge worlds. The converter accepts `VFMSH001` and `VFMSH002` files, preserves vertex normals and tile/triangle counts, and writes metre-scale GLB geometry. It rotates VoxelForge's Z-up coordinates to glTF's Y-up coordinates.
Three.js and its GLTF loader are loaded from jsDelivr at the pinned version **0.160.0**, so the browser needs internet access the first time it loads the page. Python and the viewer server need no package installs.
## Convert existing meshes
From this directory, convert one or more existing exports:
```sh
python3 convert.py \
../../../Saved/Linux/l32/capture-sw-far-v20-settled-surface.vfmesh \
../../../Saved/Linux/l32/fi-floor-v20-lod4-focus.vfmesh \
../../../Saved/Linux/linux23/capture-fi-mouth.vfmesh
```
The GLBs go into `meshes/` by default. To use another output folder, add `--out-dir /path/to/glbs`. The converter reads the source mesh directly and does not regenerate a world.
## Start and view
```sh
cd Standalone/Tools/webviewer
python3 server.py
```
Open <http://127.0.0.1:8000/> in a WebGL-capable browser on the **same machine**. The server binds only to `127.0.0.1`; it does not listen on the LAN. To serve GLBs from a different local folder:
```sh
python3 server.py --mesh-dir /path/to/glbs --port 8000
```
On Windows with Python 3, run `py -3 server.py --mesh-dir .\meshes` from this folder and open `http://127.0.0.1:8000/` on that Windows machine. For a phone or Quest browser on another device, this localhost-only server is not reachable from that device. Remote access, including a Tailscale setup, is outside this round.
## Controls
- Click the world to capture the mouse, then use **W/A/S/D** and mouse movement to fly and look. **Space** rises; **C** or **Ctrl** descends. **Esc** releases the mouse.
- On touch screens, drag the left pad to move and the right pad to look. **Touch pads** can show or hide the pads; this also makes them available in headset browsers that do not report a coarse pointer.
- Adjust flight speed with the slider. Toggle wireframe or normal-colour shading in the panel.
- Choose mesh A and an optional mesh B. Click **Show B** or press **B** to switch between them. **Fit view** frames the active mesh.
- The overlay shows the active mesh's triangles and tiles and the camera position in metres.
The viewer uses normal-based lighting and distance fog. It applies a simple viewer material; VFMSH002 resource material IDs are read and skipped by the converter.
+15
View File
@@ -0,0 +1,15 @@
# W1 web viewer report
Implemented in `Standalone/Tools/webviewer/`: a localhost-only Python server, a standard-library VFMSH001/002 to GLB converter, a pinned Three.js viewer with desktop/touch flight, speed, lighting/fog, wireframe/normal colours, stats, mesh selection, and A/B switching. See the tool README for commands and use limits.
Converted three existing meshes (source bytes → GLB bytes; conversion time):
| Mesh | Bytes | Convert | Warm local read (5 reads, mean) |
|---|---:|---:|---:|
| `capture-sw-far-v20-settled-surface` | 62,304 → 63,548 | 1.995 ms | 0.010 ms |
| `fi-floor-v20-lod4-focus` | 113,076 → 109,612 | 4.061 ms | 0.015 ms |
| `capture-fi-mouth` | 63,932 → 62,100 | 2.102 ms | 0.017 ms |
Verified: all three GLB v2 headers/chunks, JSON, vertex/normal/index buffer lengths, finite vertex data, in-range indices, and expected tile/triangle metadata; Node syntax check passed for the inline viewer module. The local read timings are warm filesystem reads, not browser decode or network timings.
Not verified: server startup and visual mesh loading in a browser. Starting `server.py --port 18765` reached socket bind and failed with `PermissionError: [Errno 1] Operation not permitted` in this execution sandbox; the attempt is logged in `Saved/Linux/w1/webviewer-server.log`. No headless browser was found. Thus GLB structure is validated, but visual rendering and in-browser load timing still need a run outside this sandbox. The server code binds explicitly to `127.0.0.1` and has no host override.
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Convert VoxelForge VFMSH001/VFMSH002 mesh scenes to binary glTF (GLB)."""
import argparse
import json
import math
import struct
import time
from pathlib import Path
HEADER_SIZE = 60
TILE_HEADER_SIZE = 36
MAX_TILES = 1_000_000
MAX_VERTICES_PER_TILE = 10_000_000
MAX_INDICES_PER_TILE = 60_000_000
MAX_MASKS_PER_TILE = 20_000_000
def read_exact(stream, size, label):
data = stream.read(size)
if len(data) != size:
raise ValueError(f"truncated {label}: expected {size} bytes, got {len(data)}")
return data
def convert(source, destination):
source = Path(source)
destination = Path(destination)
start = time.perf_counter()
positions = bytearray()
normals = bytearray()
indices = bytearray()
mins = [math.inf, math.inf, math.inf]
maxs = [-math.inf, -math.inf, -math.inf]
total_vertices = 0
total_indices = 0
minimum_index = 0xFFFFFFFF
maximum_index = 0
nonempty_tiles = 0
with source.open("rb") as stream:
header = read_exact(stream, HEADER_SIZE, "scene header")
magic = header[:8]
if magic not in (b"VFMSH001", b"VFMSH002"):
raise ValueError(f"{source}: unsupported VF mesh magic {magic!r}")
version, voxel_um, tile_count = struct.unpack_from("<3I", header, 8)
expected_version = 1 if magic == b"VFMSH001" else 2
if version != expected_version or voxel_um == 0 or tile_count > MAX_TILES:
raise ValueError(f"{source}: invalid scene header")
camera_xyz = struct.unpack_from("<3d", header, 20)
yaw, pitch = struct.unpack_from("<2d", header, 44)
if not all(math.isfinite(value) for value in (*camera_xyz, yaw, pitch)):
raise ValueError(f"{source}: non-finite camera metadata")
for tile_number in range(tile_count):
record = read_exact(stream, TILE_HEADER_SIZE, f"tile {tile_number} header")
key = struct.unpack_from("<4i", record, 0)
surface_vertices, skirt_triangles, vertex_count, index_count, mask_count = (
struct.unpack_from("<5I", record, 16)
)
if (key[3] < 0 or key[3] > 8 or vertex_count > MAX_VERTICES_PER_TILE
or index_count > MAX_INDICES_PER_TILE or index_count % 3
or mask_count > MAX_MASKS_PER_TILE
or (mask_count and mask_count != index_count // 3)):
raise ValueError(f"{source}: malformed tile {tile_number} header")
position_data = read_exact(stream, vertex_count * 12, f"tile {tile_number} positions")
normal_data = read_exact(stream, vertex_count * 12, f"tile {tile_number} normals")
if version == 2:
read_exact(stream, vertex_count * 10, f"tile {tile_number} materials")
index_data = read_exact(stream, index_count * 4, f"tile {tile_number} indices")
read_exact(stream, mask_count, f"tile {tile_number} skirt masks")
if index_count:
nonempty_tiles += 1
for index, in struct.iter_unpack("<I", index_data):
if index >= vertex_count:
raise ValueError(f"{source}: tile {tile_number} index outside vertex array")
minimum_index = min(minimum_index, index + total_vertices)
maximum_index = max(maximum_index, index + total_vertices)
indices.extend(struct.pack("<I", index + total_vertices))
for (x, y, z), (nx, ny, nz) in zip(
struct.iter_unpack("<3f", position_data),
struct.iter_unpack("<3f", normal_data)):
if not all(math.isfinite(value) for value in (x, y, z, nx, ny, nz)):
raise ValueError(f"{source}: non-finite vertex in tile {tile_number}")
# glTF uses Y-up; rotate VoxelForge's Z-up coordinates -90 degrees about X.
px, py, pz = x, z, -y
nnx, nny, nnz = nx, nz, -ny
positions.extend(struct.pack("<3f", px, py, pz))
normals.extend(struct.pack("<3f", nnx, nny, nnz))
mins[0] = min(mins[0], px)
mins[1] = min(mins[1], py)
mins[2] = min(mins[2], pz)
maxs[0] = max(maxs[0], px)
maxs[1] = max(maxs[1], py)
maxs[2] = max(maxs[2], pz)
total_vertices += vertex_count
total_indices += index_count
if total_vertices > 0xFFFFFFFF:
raise ValueError(f"{source}: combined vertex count exceeds glTF uint32 indices")
if surface_vertices > vertex_count or skirt_triangles > index_count // 3:
raise ValueError(f"{source}: inconsistent tile {tile_number} counts")
if stream.read(1):
raise ValueError(f"{source}: trailing bytes after final tile")
if total_vertices == 0 or total_indices == 0:
raise ValueError(f"{source}: scene contains no indexed geometry")
bin_data = bytearray()
views = []
def append_view(data, target):
while len(bin_data) % 4:
bin_data.append(0)
offset = len(bin_data)
bin_data.extend(data)
view = {"buffer": 0, "byteOffset": offset, "byteLength": len(data)}
if target is not None:
view["target"] = target
views.append(view)
return len(views) - 1
position_view = append_view(positions, 34962)
normal_view = append_view(normals, 34962)
index_view = append_view(indices, 34963)
camera = [camera_xyz[0], camera_xyz[2], -camera_xyz[1]]
document = {
"asset": {"version": "2.0", "generator": "VoxelForge webviewer converter"},
"scene": 0,
"scenes": [{"nodes": [0], "extras": {
"voxelForge": {
"source": source.name,
"voxelSizeMicrons": voxel_um,
"tiles": tile_count,
"nonemptyTiles": nonempty_tiles,
"triangles": total_indices // 3,
"cameraMeters": camera,
"yawDegrees": yaw,
"pitchDegrees": pitch,
"upAxis": "Y",
"units": "metres",
}
}}],
"nodes": [{"mesh": 0, "name": source.stem}],
"meshes": [{"name": source.stem, "primitives": [{
"attributes": {"POSITION": 0, "NORMAL": 1},
"indices": 2,
"material": 0,
"mode": 4,
}]}],
"materials": [{"name": "VoxelForge surface", "pbrMetallicRoughness": {
"baseColorFactor": [0.82, 0.82, 0.78, 1.0],
"metallicFactor": 0.0,
"roughnessFactor": 0.9,
}}],
"buffers": [{"byteLength": len(bin_data)}],
"bufferViews": views,
"accessors": [
{"bufferView": position_view, "componentType": 5126, "count": total_vertices,
"type": "VEC3", "min": mins, "max": maxs},
{"bufferView": normal_view, "componentType": 5126, "count": total_vertices,
"type": "VEC3"},
{"bufferView": index_view, "componentType": 5125, "count": total_indices,
"type": "SCALAR", "min": [minimum_index], "max": [maximum_index]},
],
}
json_chunk = json.dumps(document, separators=(",", ":"), allow_nan=False).encode("utf-8")
json_chunk += b" " * ((-len(json_chunk)) % 4)
bin_data.extend(b"\0" * ((-len(bin_data)) % 4))
total_length = 12 + 8 + len(json_chunk) + 8 + len(bin_data)
glb = (struct.pack("<4sII", b"glTF", 2, total_length)
+ struct.pack("<I4s", len(json_chunk), b"JSON") + json_chunk
+ struct.pack("<I4s", len(bin_data), b"BIN\0") + bin_data)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(destination.name + ".tmp")
temporary.write_bytes(glb)
temporary.replace(destination)
elapsed = time.perf_counter() - start
return {
"source_bytes": source.stat().st_size,
"output_bytes": destination.stat().st_size,
"vertices": total_vertices,
"triangles": total_indices // 3,
"tiles": tile_count,
"nonempty_tiles": nonempty_tiles,
"seconds": elapsed,
}
def main():
default_output = Path(__file__).resolve().parent / "meshes"
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inputs", nargs="+", type=Path, help="one or more .vfmesh files")
parser.add_argument("--out-dir", type=Path, default=default_output,
help=f"output folder (default: {default_output})")
args = parser.parse_args()
for source in args.inputs:
destination = args.out_dir / (source.stem + ".glb")
result = convert(source, destination)
print(f"{source} -> {destination}: {result['source_bytes']} -> "
f"{result['output_bytes']} bytes, {result['vertices']} vertices, "
f"{result['triangles']} triangles, {result['tiles']} tiles, "
f"{result['seconds'] * 1000:.3f} ms")
if __name__ == "__main__":
main()
+356
View File
@@ -0,0 +1,356 @@
<!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>
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Serve the local VoxelForge web viewer and a folder of converted .glb meshes."""
import argparse
import json
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlsplit
APP_DIR = Path(__file__).resolve().parent
def make_handler(mesh_dir):
root = mesh_dir.resolve()
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(APP_DIR), **kwargs)
def do_GET(self):
route = urlsplit(self.path).path
if route == "/api/meshes":
meshes = []
for path in sorted(root.glob("*.glb"), key=lambda item: item.name.casefold()):
try:
resolved = path.resolve(strict=True)
if resolved.parent == root and resolved.is_file():
meshes.append({"name": path.name, "bytes": resolved.stat().st_size})
except OSError:
continue
body = json.dumps(meshes, separators=(",", ":")).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
if route.startswith("/meshes/"):
name = unquote(route[len("/meshes/"):])
if not name or Path(name).name != name or not name.endswith(".glb"):
self.send_error(404)
return
path = root / name
try:
resolved = path.resolve(strict=True)
if resolved.parent != root or not resolved.is_file():
raise OSError("not a regular mesh file")
size = resolved.stat().st_size
self.send_response(200)
self.send_header("Content-Type", "model/gltf-binary")
self.send_header("Content-Length", str(size))
self.send_header("Cache-Control", "public, max-age=300")
self.end_headers()
with resolved.open("rb") as mesh:
self.copyfile(mesh, self.wfile)
except OSError:
self.send_error(404)
return
super().do_GET()
def log_message(self, fmt, *args):
print(f"[{self.log_date_time_string()}] {self.address_string()} {fmt % args}")
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mesh-dir", type=Path, default=APP_DIR / "meshes",
help="folder containing converted .glb files")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
if not 0 < args.port < 65536:
parser.error("--port must be between 1 and 65535")
args.mesh_dir.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(args.mesh_dir))
server.daemon_threads = True
print(f"VoxelForge viewer: http://127.0.0.1:{args.port}/")
print(f"Mesh folder: {args.mesh_dir.resolve()}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("Stopping viewer server")
finally:
server.server_close()
if __name__ == "__main__":
main()