diff --git a/Standalone/Tools/webviewer/README.md b/Standalone/Tools/webviewer/README.md
new file mode 100644
index 0000000..4e1f357
--- /dev/null
+++ b/Standalone/Tools/webviewer/README.md
@@ -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 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.
diff --git a/Standalone/Tools/webviewer/W1-REPORT.md b/Standalone/Tools/webviewer/W1-REPORT.md
new file mode 100644
index 0000000..7380736
--- /dev/null
+++ b/Standalone/Tools/webviewer/W1-REPORT.md
@@ -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.
diff --git a/Standalone/Tools/webviewer/convert.py b/Standalone/Tools/webviewer/convert.py
new file mode 100644
index 0000000..fd98529
--- /dev/null
+++ b/Standalone/Tools/webviewer/convert.py
@@ -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("= 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(" 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(" {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()
diff --git a/Standalone/Tools/webviewer/index.html b/Standalone/Tools/webviewer/index.html
new file mode 100644
index 0000000..1597808
--- /dev/null
+++ b/Standalone/Tools/webviewer/index.html
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+ VoxelForge World Viewer
+
+
+
+
+
+
VoxelForge World Viewer
+
+
+
+
+
12 m/s
+
+
+
Loading mesh list…
+
Triangles: —
+Tiles: —
+Camera: —
+
Click the world, then use WASD + mouse. Space/C move vertically; Esc releases the mouse.
+ B switches A/B. On touch screens, use the left pad to fly and drag the right pad to look.
+
+
+
+
+
+
+
+
+
diff --git a/Standalone/Tools/webviewer/meshes/capture-fi-mouth.glb b/Standalone/Tools/webviewer/meshes/capture-fi-mouth.glb
new file mode 100644
index 0000000..04102a5
Binary files /dev/null and b/Standalone/Tools/webviewer/meshes/capture-fi-mouth.glb differ
diff --git a/Standalone/Tools/webviewer/meshes/capture-sw-far-v20-settled-surface.glb b/Standalone/Tools/webviewer/meshes/capture-sw-far-v20-settled-surface.glb
new file mode 100644
index 0000000..ec9d72a
Binary files /dev/null and b/Standalone/Tools/webviewer/meshes/capture-sw-far-v20-settled-surface.glb differ
diff --git a/Standalone/Tools/webviewer/meshes/fi-floor-v20-lod4-focus.glb b/Standalone/Tools/webviewer/meshes/fi-floor-v20-lod4-focus.glb
new file mode 100644
index 0000000..5b75b96
Binary files /dev/null and b/Standalone/Tools/webviewer/meshes/fi-floor-v20-lod4-focus.glb differ
diff --git a/Standalone/Tools/webviewer/server.py b/Standalone/Tools/webviewer/server.py
new file mode 100644
index 0000000..88dcfc1
--- /dev/null
+++ b/Standalone/Tools/webviewer/server.py
@@ -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()