#!/usr/bin/env python3 """Capture eye-height and overview views for the three L53 heightfield profiles.""" from __future__ import annotations import argparse import json import math import os from pathlib import Path import subprocess import sys import time ROOT = Path(__file__).resolve().parents[4] OUT_DEFAULT = Path(os.environ.get("VOXELFORGE_VERIFY_OUT", ROOT / "Saved/Linux/l53")) LIMIT = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--"] PROFILES = ("periodicHills", "mirroredRelief", "flatField") Q_PER_METRE = 1024.0 sys.path.insert(0, str(ROOT / "Standalone/Tools/verify")) from runner import bounded_run, current_stamp, first_stamp def fmt(values: tuple[float, ...]) -> str: return ",".join(f"{value:.6f}" for value in values) def route_frame(manifest_path: Path) -> tuple[tuple[float, float, float], tuple[float, float, float]]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) # Core profile coordinates are 1/256 of a Core voxel. In this world that is 1024 q/m. scale = 1.0 / Q_PER_METRE candidates = [entry for entry in manifest["plan"]["strates"] if entry.get("route") and len(entry["route"]) >= 2] if not candidates: raise RuntimeError(f"no planned route points in {manifest_path}") route = candidates[0]["route"] points = [tuple(float(value) * scale for value in item["position_q"]) for item in route] lengths = [math.dist(a, b) for a, b in zip(points, points[1:])] total = sum(lengths) if total <= 1.0e-6: raise RuntimeError(f"degenerate planned route in {manifest_path}") def at(distance: float) -> tuple[float, float, float]: left = max(0.0, min(total, distance)) for a, b, length in zip(points, points[1:], lengths): if left <= length or (a, b) == (points[-2], points[-1]): if length <= 1.0e-9: return b t = left / length return tuple(a[axis] + (b[axis] - a[axis]) * t for axis in range(3)) left -= length return points[-1] center = at(total * 0.5) before, after = at(total * 0.5 - 1.0), at(total * 0.5 + 1.0) dx, dy = after[0] - before[0], after[1] - before[1] length = math.hypot(dx, dy) if length <= 1.0e-9: raise RuntimeError(f"route tangent is degenerate in {manifest_path}") return center, (dx / length, dy / length, 0.0) def run_step(name: str, command: list[str], log: Path, *, env: dict[str, str], stamped: bool = False) -> None: expected = current_stamp() if stamped else None if stamped: binary = Path(command[0]) probe = bounded_run([str(binary), "--print-source-stamp"], timeout=15) if probe is None or probe.returncode != 0 or first_stamp(probe.stdout or "") != expected: raise RuntimeError(f"{name} refused an unstamped or stale executable: {binary}") started = time.monotonic() with log.open("w", encoding="utf-8") as stream: result = subprocess.run([*LIMIT, "timeout", "1740s", *map(str, command)], cwd=ROOT, env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=1745, check=False) elapsed = time.monotonic() - started print(f"[L53CaptureRun] step={name} elapsed={elapsed:.1f}s exit={result.returncode} log={log}", flush=True) output = log.read_text(encoding="utf-8", errors="replace") if stamped and (first_stamp(output) != expected or current_stamp() != expected): raise RuntimeError(f"{name} source stamp mismatch; see {log}") if name.endswith("-quality"): for line in output.splitlines(): if line.startswith("[CaptureQuality]"): print(line, flush=True) if result.returncode != 0: raise RuntimeError(f"{name} failed; see {log}") def camera_angles(camera: tuple[float, float, float], target: tuple[float, float, float]) -> tuple[float, float]: dx, dy, dz = (target[index] - camera[index] for index in range(3)) return math.degrees(math.atan2(dx, dy)), math.degrees(math.atan2(dz, math.hypot(dx, dy))) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=OUT_DEFAULT) parser.add_argument("--fixture-dir", type=Path) parser.add_argument("--artifact-dir", type=Path, default=ROOT / "Standalone/Research/WorldAssemblerM3/Linux53/captures") parser.add_argument("--headless", type=Path, required=True) parser.add_argument("--renderer", type=Path, required=True) args = parser.parse_args() out = args.out.resolve() fixture_dir = (args.fixture_dir or out / "fixtures").resolve() artifact_dir = args.artifact_dir.resolve() png_dir = out / "capture-png" mesh_dir = out / "profile-meshes" for directory in (out, png_dir, mesh_dir, artifact_dir): directory.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env["LP_NUM_THREADS"] = "12" env["XDG_CACHE_HOME"] = str(out / "xdg-cache") for profile in PROFILES: fixture = fixture_dir / f"style-override-profile-{profile}.json" manifest = fixture_dir / f"style-override-profile-{profile}-manifest.json" if not fixture.is_file() or not manifest.is_file(): raise RuntimeError(f"missing profile fixture or manifest for {profile}") center, tangent = route_frame(manifest) cx, cy, cz = center tx, ty, _ = tangent eye = (cx, cy, cz + 0.72) eye_target = (cx + tx * 15.0, cy + ty * 15.0, cz + 0.72) overview_camera = (cx - tx * 48.0 - ty * 25.0, cy - ty * 48.0 + tx * 25.0, cz + 43.0) overview_target = (cx + tx * 22.0, cy + ty * 22.0, cz + 0.4) for view, camera, target, clip_radius in ( ("eye", eye, eye_target, 3), ("overview", overview_camera, overview_target, 6), ): stem = f"{profile}-{view}" mesh = mesh_dir / f"{stem}.vfmesh" png = png_dir / f"{stem}.png" image = artifact_dir / f"{stem}.jpg" yaw, pitch = camera_angles(camera, target) camera_spec = (*camera, yaw, pitch) headless_command = [args.headless, fixture, "--workers=12", "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", "--force-generate", f"--clip-radius={clip_radius}", "--max-clip-level=0", "--max-level=0", "--render-distance-chunks=0", "--recipe=surface-world", f"--camera={fmt(camera_spec)}", f"--mesh-export={mesh}", f"--world-assembler-manifest-output={manifest}"] run_step(f"{stem}-export", headless_command, out / f"capture-{stem}-export.log", env=env, stamped=True) if not mesh.is_file() or mesh.stat().st_size == 0: raise RuntimeError(f"headless export produced no mesh for {stem}") focus = (*target, 54.0 if view == "overview" else 26.0) renderer_command = [args.renderer, mesh, png, "--size=1600x900", "--fov-deg=72", f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}", f"--pitch-deg={pitch:.6f}", "--tile-skirts=off", "--shade=readable", "--headlamp=on", "--height-tint=on", f"--height-center-m={cz:.6f}", "--height-range-m=10", "--tile-edges=off", f"--focus-region={fmt(focus)}"] run_step(f"{stem}-render", renderer_command, out / f"capture-{stem}-render.log", env=env, stamped=True) run_step(f"{stem}-jpeg", ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", png, "-frames:v", "1", "-q:v", "4", image], out / f"capture-{stem}-jpeg.log", env=env) quality = ROOT / "Standalone/Tools/verify/capture_quality.py" run_step(f"{stem}-quality", [sys.executable, quality, image], out / f"capture-{stem}-quality.log", env=env) if not image.is_file() or not 0 < image.stat().st_size <= 300_000: raise RuntimeError(f"capture size is invalid for {stem}") print(f"[L53HeightfieldCapture] profile={profile} view={view} file={image} " f"camera_m=({fmt(camera)}) target_m=({fmt(target)}) " f"bytes={image.stat().st_size} status=PASS", flush=True) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as error: print(f"[L53HeightfieldCapture] status=FAIL error={error}", file=sys.stderr, flush=True) raise SystemExit(1)