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()