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; }
+ }