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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-10-29 00:10:28 +01:00
1 parent 2ef0ca72b2
commit 95cf8de107
5 files changed
+239 -15

No files matched your search

+4
View File
@@ -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
+14 -2
View File
@@ -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 <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:
Open <http://127.0.0.1:8000/> 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 <tailscale-ip>` and open `http://<tailscale-ip>: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
+23
View File
@@ -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.
+142
View File
@@ -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())
+56 -13
View File
@@ -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; }
}
</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/"}}
@@ -48,7 +68,11 @@
</head>
<body>
<div id="panel">
<div id="panelHeader">
<h1>VoxelForge World Viewer</h1>
<button id="panelToggle" type="button" aria-label="Collapse menu" aria-expanded="true" title="Collapse menu">−</button>
</div>
<div id="panelContent">
<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>
@@ -56,13 +80,14 @@
<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="status" role="status" aria-live="polite" data-state="loading">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>
<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>
@@ -106,7 +131,10 @@ Camera: —</div>
function updateCameraRotation() {
camera.rotation.set(pitch, yaw, 0, 'YXZ');
}
function setStatus(message) { status.textContent = message; }
function setStatus(message, state = 'info') {
status.textContent = message;
status.dataset.state = state;
}
function updateHud() {
const item = slots[active];
const p = camera.position;
@@ -176,7 +204,7 @@ Camera: —</div>
updateHud();
return;
}
setStatus(`Loading ${name}…`);
setStatus(`Loading ${name}…`, 'loading');
const start = performance.now();
loader.load(`/meshes/${encodeURIComponent(name)}`, (gltf) => {
if (slots[slot]) scene.remove(slots[slot].root);
@@ -209,14 +237,15 @@ Camera: —</div>
}
setStatus(`${name} loaded in ${(performance.now() - start).toFixed(1)} ms`);
}, (event) => {
if (event.total) setStatus(`Loading ${name}: ${Math.round(event.loaded / event.total * 100)}%`);
if (event.total) setStatus(`Loading ${name}: ${Math.round(event.loaded / event.total * 100)}%`, 'loading');
}, (error) => {
console.error(error);
setStatus(`Could not load ${name}; check the server and mesh file.`);
setStatus(`Could not load ${name}. Check the server connection and mesh file.`, 'error');
});
}
async function populateMeshes() {
setStatus('Loading mesh list…', 'loading');
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();
@@ -230,14 +259,28 @@ Camera: —</div>
}
if (!meshes.length) {
main.add(new Option('No .glb files found', ''));
setStatus('Convert a .vfmesh file into the configured mesh folder.');
setStatus('No GLB meshes found. Export a world into the meshes folder.', 'error');
return;
}
main.value = meshes[0].name;
main.value = meshes.some((mesh) => mesh.name === 'sw-arrival.glb')
? 'sw-arrival.glb' : meshes[0].name;
loadMesh(main.value, 'A');
setStatus(`${meshes.length} mesh${meshes.length === 1 ? '' : 'es'} available`);
}
function setPanelCollapsed(collapsed) {
const panel = $('panel');
const toggle = $('panelToggle');
panel.classList.toggle('collapsed', collapsed);
toggle.textContent = collapsed ? '☰' : '−';
toggle.setAttribute('aria-label', collapsed ? 'Expand menu' : 'Collapse menu');
toggle.setAttribute('aria-expanded', String(!collapsed));
toggle.title = collapsed ? 'Expand menu' : 'Collapse menu';
}
$('panelToggle').addEventListener('click', () => {
setPanelCollapsed(!$('panel').classList.contains('collapsed'));
});
setPanelCollapsed(matchMedia('(max-width: 540px)').matches);
$('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'));
@@ -347,7 +390,7 @@ Camera: —</div>
});
populateMeshes().catch((error) => {
console.error(error);
setStatus('Could not contact the local viewer server.');
setStatus(`Could not load the mesh list: ${error.message || 'server connection failed'}.`, 'error');
});
updateCameraRotation();
requestAnimationFrame(animate);