From 95cf8de10723f2dffe7b3dfc116ecf350c7114ae Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Thu, 29 Oct 2026 00:10:28 +0100 Subject: [PATCH] tools: web viewer shows the owner's world; collapsible phone menu (W2) 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 Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1 --- .gitignore | 4 + Standalone/Tools/webviewer/README.md | 16 ++- Standalone/Tools/webviewer/W2-REPORT.md | 23 ++++ Standalone/Tools/webviewer/export_world.py | 142 +++++++++++++++++++++ Standalone/Tools/webviewer/index.html | 71 +++++++++-- 5 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 Standalone/Tools/webviewer/W2-REPORT.md create mode 100644 Standalone/Tools/webviewer/export_world.py diff --git a/.gitignore b/.gitignore index 84f4f4b..90316d2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ /Standalone/data/*.err /Standalone/data/*.vfmesh /Standalone/Research/**/*.log + +# Web viewer world exports (regenerate with Standalone/Tools/webviewer/export_world.py) +/Standalone/Tools/webviewer/meshes/*-arrival.glb +/Standalone/Tools/webviewer/meshes/*-passage.glb diff --git a/Standalone/Tools/webviewer/README.md b/Standalone/Tools/webviewer/README.md index 4e1f357..0050d8a 100644 --- a/Standalone/Tools/webviewer/README.md +++ b/Standalone/Tools/webviewer/README.md @@ -17,6 +17,16 @@ python3 convert.py \ 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. +## Refresh the owner world previews + +From the repository root, run: + +```sh +python3 Standalone/Tools/webviewer/export_world.py +``` + +This uses the existing `Saved/Linux/l101/bin/VoxelForgeCoreHeadless-l101-clang` exporter to regenerate LOD0–2 arrival views for SW, FI, TN, and VS, plus a FI passage view. It applies the CPU affinity and memory limits used by the capture runners, writes the GLBs into `Standalone/Tools/webviewer/meshes/`, and keeps per-view logs and a size/count summary in `Saved/Linux/w2/`. To use another compatible headless binary, pass `--headless-binary /path/to/VoxelForgeCoreHeadless-*`. + ## Start and view ```sh @@ -24,13 +34,15 @@ 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: +Open in a WebGL-capable browser on the **same machine**. The server binds to `127.0.0.1` by default. 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. +For a phone connected over Tailscale, bind to the viewer machine's Tailscale address with `--host ` and open `http://:8000/` on the phone. Keep the browser online so it can load the pinned Three.js modules from jsDelivr. + +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. ## Controls diff --git a/Standalone/Tools/webviewer/W2-REPORT.md b/Standalone/Tools/webviewer/W2-REPORT.md new file mode 100644 index 0000000..8c5f9dc --- /dev/null +++ b/Standalone/Tools/webviewer/W2-REPORT.md @@ -0,0 +1,23 @@ +# W2 web viewer report + +Exported the owner's `Standalone/data/owner_world.json` with the existing L101 headless mesh exporter, `--clip-radius=3`, and LOD0–2. The five GLBs are in `meshes/`; sizes are decimal MB. + +| View | GLB size | Triangles | +|---|---:|---:| +| `sw-arrival.glb` | 15.89 MB | 605,310 | +| `fi-arrival.glb` | 31.29 MB | 1,214,996 | +| `tn-arrival.glb` | 30.71 MB | 1,199,460 | +| `vs-arrival.glb` | 27.72 MB | 1,096,822 | +| `fi-passage.glb` | 45.09 MB | 1,743,474 | + +The web menu now collapses to a button and starts collapsed at phone widths. Phone text and controls are smaller. Mesh-list and GLB loading show progress, and failures display an error. The viewer selects `sw-arrival.glb` by default when it is present. + +Refresh all five views from the repository root with: + +```sh +python3 Standalone/Tools/webviewer/export_world.py +``` + +The script applies the requested affinity and 6 GiB address-space limit, converts through the W1 `convert.py`, and writes run logs plus `export-summary.json` to `Saved/Linux/w2/`. No position or normal quantization was needed; the largest file is 45.09 MB. + +Not verified: loading and visually inspecting these new GLBs in the owner's phone browser, or measuring phone transfer time and memory use. The headless exports and conversions completed successfully. diff --git a/Standalone/Tools/webviewer/export_world.py b/Standalone/Tools/webviewer/export_world.py new file mode 100644 index 0000000..455773c --- /dev/null +++ b/Standalone/Tools/webviewer/export_world.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Export phone-sized LOD0-2 arrival views of the owner world as viewer GLBs.""" +from __future__ import annotations + +import argparse +import json +import struct +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +WEBVIEWER = Path(__file__).resolve().parent +DATA = ROOT / "Standalone/data/owner_world.json" +DEFAULT_BINARY = ROOT / "Saved/Linux/l101/bin/VoxelForgeCoreHeadless-l101-clang" +LOG_DIR = ROOT / "Saved/Linux/w2" +MESH_DIR = WEBVIEWER / "meshes" +CONVERTER = WEBVIEWER / "convert.py" + +# Arrival positions used by the existing L70-L80 headless arrival/census runners. +VIEWS = ( + ("sw-arrival", (8.0, 16.0, -372.2626953125)), + ("fi-arrival", (25.8369140625, 3.19921875, -737.0)), + ("tn-arrival", (19.166015625, -14.2001953125, -753.3740234375)), + ("vs-arrival", (-20.0625, -5.068359375, -900.353515625)), + ("fi-passage", (0.0, 0.0, -600.0)), +) + + +def mesh_stats(path: Path) -> dict: + triangles = tiles = vertices = 0 + lod_counts: dict[int, int] = {} + with path.open("rb") as stream: + header = stream.read(60) + if len(header) != 60 or header[:8] not in (b"VFMSH001", b"VFMSH002"): + raise RuntimeError(f"invalid VFMSH output: {path}") + version, _, tiles = struct.unpack_from("<3I", header, 8) + expected = 1 if header[:8] == b"VFMSH001" else 2 + if version != expected: + raise RuntimeError(f"VFMSH version mismatch: {path}") + for _ in range(tiles): + record = stream.read(36) + if len(record) != 36: + raise RuntimeError(f"truncated tile header: {path}") + key = struct.unpack_from("<4i", record, 0) + _, _, vertex_count, index_count, mask_count = struct.unpack_from("<5I", record, 16) + triangles += index_count // 3 + vertices += vertex_count + lod_counts[key[3]] = lod_counts.get(key[3], 0) + 1 + stream.seek(vertex_count * (34 if version == 2 else 24) + + index_count * 4 + mask_count, 1) + if stream.read(1): + raise RuntimeError(f"trailing bytes after VFMSH tiles: {path}") + return {"tiles": tiles, "vertices": vertices, "triangles": triangles, + "lod_counts": lod_counts} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--headless-binary", type=Path, default=DEFAULT_BINARY, + help=f"headless exporter binary (default: {DEFAULT_BINARY})") + parser.add_argument("--clip-radius", type=int, default=3, + help="headless neighborhood radius (default: 3)") + parser.add_argument("--max-lod", type=int, default=2, + help="highest exported LOD (default: 2)") + parser.add_argument("--timeout-seconds", type=int, default=1740, + help="per-view limit; maximum is 1740 seconds") + args = parser.parse_args() + + binary = args.headless_binary if args.headless_binary.is_absolute() else ROOT / args.headless_binary + if args.timeout_seconds < 1 or args.timeout_seconds > 1740: + parser.error("--timeout-seconds must be between 1 and 1740") + if args.clip_radius < 1 or args.max_lod < 0: + parser.error("--clip-radius must be positive and --max-lod cannot be negative") + for required in (binary, DATA, CONVERTER): + if not required.is_file(): + parser.error(f"required file not found: {required}") + + LOG_DIR.mkdir(parents=True, exist_ok=True) + MESH_DIR.mkdir(parents=True, exist_ok=True) + rows = [] + with tempfile.TemporaryDirectory(prefix=".w2-export-", dir=WEBVIEWER) as scratch: + scratch_dir = Path(scratch) + for name, camera in VIEWS: + source = scratch_dir / f"{name}.vfmesh" + camera_arg = ",".join(f"{value:.12f}" for value in (*camera, 0.0, 0.0)) + headless_log = LOG_DIR / f"{name}-headless.log" + command = [ + "nice", "-n", "19", "taskset", "-c", "0-11", + "prlimit", "--as=6442450944", "--", + "timeout", f"{args.timeout_seconds}s", str(binary), str(DATA), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--workers=8", "--force-generate", f"--clip-radius={args.clip_radius}", + f"--max-clip-level={args.max_lod}", f"--max-level={args.max_lod}", + "--render-distance-chunks=0", f"--camera={camera_arg}", + f"--mesh-export={source}", + ] + started = time.monotonic() + with headless_log.open("w", encoding="utf-8") as log: + result = subprocess.run(command, cwd=ROOT, stdout=log, + stderr=subprocess.STDOUT, check=False, + timeout=args.timeout_seconds + 15) + if result.returncode != 0 or not source.is_file() or source.stat().st_size == 0: + raise RuntimeError( + f"headless export failed for {name} (exit={result.returncode}); see {headless_log}") + + stats = mesh_stats(source) + convert_log = LOG_DIR / f"{name}-convert.log" + with convert_log.open("w", encoding="utf-8") as log: + converted = subprocess.run( + [sys.executable, str(CONVERTER), str(source), "--out-dir", str(MESH_DIR)], + cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, check=False) + destination = MESH_DIR / f"{name}.glb" + if converted.returncode != 0 or not destination.is_file() or destination.stat().st_size == 0: + raise RuntimeError(f"GLB conversion failed for {name}; see {convert_log}") + + row = { + "name": name, + "glb": str(destination.relative_to(ROOT)), + "bytes": destination.stat().st_size, + "triangles": stats["triangles"], + "tiles": stats["tiles"], + "vertices": stats["vertices"], + "lod_counts": stats["lod_counts"], + "wall_s": round(time.monotonic() - started, 3), + } + rows.append(row) + print(f"{name}: {destination.stat().st_size / 1_000_000:.2f} MB, " + f"{stats['triangles']:,} triangles, {stats['tiles']:,} tiles, " + f"LOD tiles {json.dumps(stats['lod_counts'], sort_keys=True)}") + + summary_path = LOG_DIR / "export-summary.json" + summary_path.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8") + print(f"Summary: {summary_path.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Tools/webviewer/index.html b/Standalone/Tools/webviewer/index.html index 1597808..f0d6ce7 100644 --- a/Standalone/Tools/webviewer/index.html +++ b/Standalone/Tools/webviewer/index.html @@ -13,7 +13,14 @@ #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; } + #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%; } @@ -29,6 +36,8 @@ #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; } @@ -38,9 +47,20 @@ #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; } } + @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; } + }