passages: memoized exact MC midpoints + wider conservative sweep bounds (L101, exact vs L100)
Off-grid census by caller (MC midpoints, skirts, far shells); all 63.0M sweep misses accounted (1.9M exact-route fallbacks, 61.1M counted bound skips). Exact MC midpoint queries memoized by float bits (Unreal enables it too); feature bounds expanded. L100/L101 whole-view hashes equal in all four strata (v216 unchanged); 0 added/removed crossings; 15 capture pairs pixel-identical. Near-ready @W12 L100 -> L101: FI 1.77 -> 1.65, TN 1.66 -> 1.54, SW 0.85 -> 0.84, VS 1.03 -> 1.03 s (L98 2.28/2.23/1.02/1.11). Fast tier PASS; Linux UE host builds. Unreal arrival traces not obtained (the runner wrongly applied the 6 GiB address-space cap to Unreal). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
1 parent
2d5822cdf7
commit
55e20f4a7e
48 files changed
+2087
-38
No files matched your search
@@ -0,0 +1,602 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture matched L100/L101 passage mouth and mid-route views."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import zlib
|
||||
from array import array
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = ROOT / "Saved/Linux/l101/captures"
|
||||
IMAGE_DIR = Path(__file__).resolve().parent / "captures"
|
||||
DATA = ROOT / "Standalone/data/owner_world.json"
|
||||
LAYOUT_PATH = ROOT / "Saved/Linux/l99/resolved-sw.json"
|
||||
BINARIES = {
|
||||
"before-l100": ROOT / "Saved/Linux/l100/bin/VoxelForgeCoreHeadless-l100-clang",
|
||||
"after-l101": ROOT / "Saved/Linux/l101/bin/VoxelForgeCoreHeadless-l101-clang",
|
||||
}
|
||||
RENDERER = ROOT / "Saved/Linux/l101/bin/VoxelForgeOffscreenCapture-l101-clang"
|
||||
VOXEL_Q = 256.0
|
||||
CORE_UM_PER_VOXEL = 250_000.0
|
||||
ROUTE_POINTS = {}
|
||||
ROUTE_LENGTHS = {}
|
||||
|
||||
|
||||
def add(a, b):
|
||||
return tuple(x + y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def sub(a, b):
|
||||
return tuple(x - y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def mul(a, s):
|
||||
return tuple(x * s for x in a)
|
||||
|
||||
|
||||
def dot(a, b):
|
||||
return sum(x * y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def norm(a):
|
||||
length = math.sqrt(dot(a, a))
|
||||
if length < 1.0e-8:
|
||||
raise ValueError("degenerate capture direction")
|
||||
return tuple(x / length for x in a)
|
||||
|
||||
|
||||
def fmt(values):
|
||||
return ",".join(f"{value:.9f}" for value in values)
|
||||
|
||||
|
||||
def angles(camera, target):
|
||||
delta = sub(target, camera)
|
||||
return (math.degrees(math.atan2(delta[0], delta[1])),
|
||||
math.degrees(math.atan2(delta[2], math.hypot(delta[0], delta[1]))))
|
||||
|
||||
|
||||
def interpolate(points, distance):
|
||||
remaining = max(0.0, distance)
|
||||
for a, b in zip(points, points[1:]):
|
||||
delta = sub(b[0], a[0])
|
||||
length = math.sqrt(dot(delta, delta))
|
||||
if length < 1.0e-8:
|
||||
continue
|
||||
if remaining <= length:
|
||||
t = remaining / length
|
||||
return (tuple(a[0][i] + delta[i] * t for i in range(3)),
|
||||
a[1] + (b[1] - a[1]) * t)
|
||||
remaining -= length
|
||||
return points[-1]
|
||||
|
||||
|
||||
def route_frame(points, distance):
|
||||
center, radius = interpolate(points, distance)
|
||||
before, _ = interpolate(points, max(0.0, distance - 0.25))
|
||||
after, _ = interpolate(points, min(sum(
|
||||
math.sqrt(dot(sub(b[0], a[0]), sub(b[0], a[0])))
|
||||
for a, b in zip(points, points[1:])), distance + 0.25))
|
||||
tangent = norm(sub(after, before))
|
||||
horizontal = norm((tangent[0], tangent[1], 0.0))
|
||||
side = (-horizontal[1], horizontal[0], 0.0)
|
||||
return center, radius, tangent, side
|
||||
|
||||
|
||||
def route_length(points):
|
||||
return sum(math.sqrt(dot(sub(b[0], a[0]), sub(b[0], a[0])))
|
||||
for a, b in zip(points, points[1:]))
|
||||
|
||||
|
||||
def nearest_route(points, point):
|
||||
best = None
|
||||
distance_along = 0.0
|
||||
for a, b in zip(points, points[1:]):
|
||||
delta = sub(b[0], a[0])
|
||||
length_sq = dot(delta, delta)
|
||||
if length_sq < 1.0e-10:
|
||||
continue
|
||||
segment_length = math.sqrt(length_sq)
|
||||
t = max(0.0, min(1.0, dot(sub(point, a[0]), delta) / length_sq))
|
||||
center = add(a[0], mul(delta, t))
|
||||
tangent = norm(delta)
|
||||
radial = sub(point, center)
|
||||
radial = sub(radial, mul(tangent, dot(radial, tangent)))
|
||||
distance = math.sqrt(dot(sub(point, center), sub(point, center)))
|
||||
if best is None or distance < best[0]:
|
||||
radius = a[1] + (b[1] - a[1]) * t
|
||||
best = (distance, center, tangent, radial, radius,
|
||||
distance_along + t * segment_length)
|
||||
distance_along += segment_length
|
||||
if best is None:
|
||||
raise ValueError("passage route has no non-degenerate segment")
|
||||
return best
|
||||
|
||||
|
||||
def run(name, command, headless):
|
||||
log = OUT / f"{name}.log"
|
||||
wrapped = ["taskset", "-c", "0-11"]
|
||||
if headless:
|
||||
wrapped += ["prlimit", "--as=6442450944", "--"]
|
||||
wrapped += ["timeout", "1740s", *map(str, command)]
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = subprocess.run(wrapped, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT, check=False, timeout=1745)
|
||||
row = {"name": name, "exit_code": result.returncode,
|
||||
"wall_s": round(time.monotonic() - started, 3),
|
||||
"log": str(log.relative_to(ROOT))}
|
||||
print("[L101CaptureRun] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"capture step failed: {name}; see {log}")
|
||||
return row
|
||||
|
||||
|
||||
def nearest_surface_anchor(mesh, point, tile_search_margin_m=20.0):
|
||||
"""Return the nearest surface vertex and outward mesh normal from a VFMSH scene."""
|
||||
best_distance_sq = float("inf")
|
||||
best_position = best_normal = None
|
||||
with mesh.open("rb") as stream:
|
||||
magic = stream.read(8)
|
||||
has_materials = magic == b"VFMSH002"
|
||||
if magic not in (b"VFMSH001", b"VFMSH002"):
|
||||
raise RuntimeError(f"unsupported mesh scene header: {mesh}")
|
||||
version, voxel_um, tile_count = struct.unpack("<3I", stream.read(12))
|
||||
if version != (2 if has_materials else 1):
|
||||
raise RuntimeError(f"mesh scene version mismatch: {mesh}")
|
||||
stream.seek(40, 1) # stored camera position, yaw, and pitch
|
||||
for _ in range(tile_count):
|
||||
key = struct.unpack("<4i", stream.read(16))
|
||||
surface_count, _, vertex_count, index_count, mask_count = struct.unpack(
|
||||
"<5I", stream.read(20))
|
||||
extent = (32 << key[3]) * voxel_um / 1_000_000.0
|
||||
origin = (key[0] * extent, key[1] * extent, key[2] * extent)
|
||||
if any(point[axis] < origin[axis] - tile_search_margin_m or
|
||||
point[axis] > origin[axis] + extent + tile_search_margin_m
|
||||
for axis in range(3)):
|
||||
stream.seek(vertex_count * 24 + (10 * vertex_count if has_materials else 0)
|
||||
+ index_count * 4 + mask_count, 1)
|
||||
continue
|
||||
positions = array("f")
|
||||
positions.frombytes(stream.read(vertex_count * 12))
|
||||
normals = array("f")
|
||||
normals.frombytes(stream.read(vertex_count * 12))
|
||||
if has_materials:
|
||||
stream.seek(vertex_count * 10, 1)
|
||||
stream.seek(index_count * 4 + mask_count, 1)
|
||||
if struct.pack("=I", 1) != struct.pack("<I", 1):
|
||||
positions.byteswap()
|
||||
normals.byteswap()
|
||||
for index in range(min(surface_count, vertex_count)):
|
||||
offset = index * 3
|
||||
# VFMSH stores vertex coordinates in world space; tile origin
|
||||
# is metadata for edge rendering and must not be added again.
|
||||
position = tuple(float(positions[offset + axis]) for axis in range(3))
|
||||
delta = sub(position, point)
|
||||
distance_sq = dot(delta, delta)
|
||||
if distance_sq >= best_distance_sq:
|
||||
continue
|
||||
raw_normal = tuple(float(normals[offset + axis]) for axis in range(3))
|
||||
if dot(raw_normal, raw_normal) < 1.0e-10:
|
||||
continue
|
||||
best_distance_sq = distance_sq
|
||||
best_position = position
|
||||
best_normal = norm(raw_normal)
|
||||
if best_position is None:
|
||||
raise RuntimeError(f"no nearby surface vertices in mesh scene: {mesh}")
|
||||
return {"position_m": best_position, "normal": best_normal,
|
||||
"distance_m": math.sqrt(best_distance_sq)}
|
||||
|
||||
|
||||
def make_passages(layout):
|
||||
q_to_m = layout["core_voxel_size_um"] / (VOXEL_Q * 1_000_000.0)
|
||||
passages = []
|
||||
all_routes = []
|
||||
for passage in layout["passages"]:
|
||||
controls = [(tuple(float(q) * q_to_m for q in p["position_q"]),
|
||||
float(p["radius_q"]) * q_to_m)
|
||||
for p in passage["control_points"]]
|
||||
upper = tuple(float(q) * q_to_m for q in passage["upper_mouth_q"])
|
||||
lower = tuple(float(q) * q_to_m for q in passage["lower_mouth_q"])
|
||||
ui = min(range(len(controls)), key=lambda i: dot(sub(controls[i][0], upper),
|
||||
sub(controls[i][0], upper)))
|
||||
li = min(range(len(controls)), key=lambda i: dot(sub(controls[i][0], lower),
|
||||
sub(controls[i][0], lower)))
|
||||
points = controls[ui:li + 1]
|
||||
if len(points) < 2:
|
||||
raise RuntimeError(f"invalid mouth path: {passage['stable_id']}")
|
||||
upper_offset = math.sqrt(dot(sub(points[0][0], upper), sub(points[0][0], upper)))
|
||||
lower_offset = math.sqrt(dot(sub(points[-1][0], lower), sub(points[-1][0], lower)))
|
||||
length = route_length(points)
|
||||
passage_views = []
|
||||
for endpoint, distance, camera_distance, outward_sign in (
|
||||
("upper-mouth", 0.0, min(length, 7.4), -1.0),
|
||||
("lower-mouth", length, max(0.0, length - 5.5), 1.0)):
|
||||
center, _, tangent, side = route_frame(points, distance)
|
||||
camera_center, _, _, camera_side = route_frame(points, camera_distance)
|
||||
camera = (camera_center[0] + camera_side[0] * 2.5,
|
||||
camera_center[1] + camera_side[1] * 2.5, camera_center[2])
|
||||
outward = mul(tangent, outward_sign)
|
||||
target = (center[0] + outward[0] * 8.0 + side[0] * 2.5,
|
||||
center[1] + outward[1] * 8.0 + side[1] * 2.5, center[2])
|
||||
yaw, pitch = angles(camera, target)
|
||||
passage_views.append({"name": f"{passage['stable_id']}-{endpoint}",
|
||||
"kind": "mouth", "focus": center, "camera": camera, "target": target,
|
||||
"yaw": yaw, "pitch": pitch, "clip": None, "fov": 80.0,
|
||||
"focus_radius": 20.0, "passage": passage["stable_id"],
|
||||
"camera_location": "passage-air"})
|
||||
for fraction in (0.25, 0.50, 0.75):
|
||||
distance = length * fraction
|
||||
center, radius, _, side = route_frame(points, distance)
|
||||
camera = (center[0] + side[0] * 0.75,
|
||||
center[1] + side[1] * 0.75,
|
||||
center[2] - radius + 1.6)
|
||||
target_pos, target_radius = interpolate(
|
||||
points, min(length, distance + 14.0))
|
||||
target = (target_pos[0] + side[0] * 2.35,
|
||||
target_pos[1] + side[1] * 2.35,
|
||||
target_pos[2] - target_radius + 1.8)
|
||||
yaw, pitch = angles(camera, target)
|
||||
pct = int(round(fraction * 100))
|
||||
passage_views.append({"name": f"{passage['stable_id']}-mid-{pct:02d}",
|
||||
"kind": "mid-tunnel", "route_fraction": fraction,
|
||||
"focus": center, "camera": camera, "target": target,
|
||||
"yaw": yaw, "pitch": pitch, "clip": None,
|
||||
"fov": 78.0, "focus_radius": 24.0,
|
||||
"passage": passage["stable_id"]})
|
||||
passages.append({"stable_id": passage["stable_id"], "route_length_m": length,
|
||||
"control_points": len(points), "upper_mouth_anchor_offset_m": upper_offset,
|
||||
"lower_mouth_anchor_offset_m": lower_offset, "views": passage_views})
|
||||
all_routes.append((passage["stable_id"], points))
|
||||
return passages, all_routes
|
||||
|
||||
|
||||
def make_hotspot_views(routes, reach_summary):
|
||||
views = []
|
||||
for series, rows, value_key, location_key, region_key in (
|
||||
("displacement", reach_summary["top_displacement_tiles"],
|
||||
"max_displacement_cm", "displacement_location_metres", "displacement_region"),
|
||||
("normal", reach_summary["top_normal_tiles"],
|
||||
"max_normal_degrees", "normal_location_metres", "normal_region"),
|
||||
):
|
||||
for index, row in enumerate(rows, 1):
|
||||
point = tuple(row[location_key])
|
||||
candidates = [(nearest_route(route, point), name, route)
|
||||
for name, route in routes]
|
||||
(distance, center, tangent, radial, radius, along), nearest_id, route = min(
|
||||
candidates, key=lambda item: item[0][0])
|
||||
if math.sqrt(dot(radial, radial)) < 0.25:
|
||||
horizontal = norm((tangent[0], tangent[1], 0.0))
|
||||
radial = (-horizontal[1], horizontal[0], 0.0)
|
||||
outward = norm(radial)
|
||||
camera = add(point, mul(outward, 20.0))
|
||||
target = point
|
||||
plane = None
|
||||
yaw, pitch = angles(camera, target)
|
||||
views.append({"name": f"top-{series}-{index:02d}", "kind": f"top-{series}",
|
||||
"focus": point, "camera": camera, "target": target,
|
||||
"yaw": yaw, "pitch": pitch, "clip": plane, "fov": 68.0,
|
||||
"focus_radius": 18.0, "strate": row["strate"],
|
||||
"value": row[value_key], "region": row[region_key],
|
||||
"camera_anchor": "nearest-surface-l100-v1", "nearest_passage": nearest_id,
|
||||
"route_distance_m": distance})
|
||||
return views
|
||||
|
||||
|
||||
def decode_png(path):
|
||||
"""Decode 8-bit RGB/RGBA PNG pixels using only the Python standard library."""
|
||||
data = path.read_bytes()
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise RuntimeError(f"not a PNG: {path}")
|
||||
width = height = bit_depth = color_type = interlace = None
|
||||
compressed = bytearray()
|
||||
offset = 8
|
||||
while offset < len(data):
|
||||
length = struct.unpack_from(">I", data, offset)[0]
|
||||
kind = data[offset + 4:offset + 8]
|
||||
chunk = data[offset + 8:offset + 8 + length]
|
||||
if kind == b"IHDR":
|
||||
width, height, bit_depth, color_type, compression, filtering, interlace = \
|
||||
struct.unpack(">IIBBBBB", chunk)
|
||||
if compression or filtering or interlace:
|
||||
raise RuntimeError(f"unsupported PNG coding in {path}")
|
||||
elif kind == b"IDAT":
|
||||
compressed.extend(chunk)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
offset += 12 + length
|
||||
if bit_depth != 8 or color_type not in (2, 6):
|
||||
raise RuntimeError(f"expected 8-bit RGB/RGBA PNG: {path}")
|
||||
channels = 3 if color_type == 2 else 4
|
||||
stride = width * channels
|
||||
encoded = zlib.decompress(compressed)
|
||||
pixels = bytearray(height * stride)
|
||||
source_offset = 0
|
||||
for y in range(height):
|
||||
filter_type = encoded[source_offset]
|
||||
source_offset += 1
|
||||
row = bytearray(encoded[source_offset:source_offset + stride])
|
||||
source_offset += stride
|
||||
prior_offset = (y - 1) * stride
|
||||
for index in range(stride):
|
||||
left = row[index - channels] if index >= channels else 0
|
||||
above = pixels[prior_offset + index] if y else 0
|
||||
upper_left = pixels[prior_offset + index - channels] \
|
||||
if y and index >= channels else 0
|
||||
if filter_type == 1:
|
||||
row[index] = (row[index] + left) & 255
|
||||
elif filter_type == 2:
|
||||
row[index] = (row[index] + above) & 255
|
||||
elif filter_type == 3:
|
||||
row[index] = (row[index] + ((left + above) >> 1)) & 255
|
||||
elif filter_type == 4:
|
||||
estimate = left + above - upper_left
|
||||
left_distance = abs(estimate - left)
|
||||
above_distance = abs(estimate - above)
|
||||
upper_left_distance = abs(estimate - upper_left)
|
||||
if left_distance <= above_distance and left_distance <= upper_left_distance:
|
||||
predictor = left
|
||||
elif above_distance <= upper_left_distance:
|
||||
predictor = above
|
||||
else:
|
||||
predictor = upper_left
|
||||
row[index] = (row[index] + predictor) & 255
|
||||
elif filter_type != 0:
|
||||
raise RuntimeError(f"unsupported PNG filter {filter_type}: {path}")
|
||||
start = y * stride
|
||||
pixels[start:start + stride] = row
|
||||
if channels == 3:
|
||||
return width, height, pixels
|
||||
rgb = bytearray(width * height * 3)
|
||||
rgb[0::3] = pixels[0::4]
|
||||
rgb[1::3] = pixels[1::4]
|
||||
rgb[2::3] = pixels[2::4]
|
||||
return width, height, rgb
|
||||
|
||||
|
||||
def image_geometry_metrics(path):
|
||||
width, height, rgb = decode_png(path)
|
||||
color_counts = {}
|
||||
for offset in range(0, len(rgb), 3):
|
||||
color = (rgb[offset], rgb[offset + 1], rgb[offset + 2])
|
||||
color_counts[color] = color_counts.get(color, 0) + 1
|
||||
total = width * height
|
||||
visible = total - max(color_counts.values())
|
||||
return {"width": width, "height": height, "unique_rgb": len(color_counts),
|
||||
"visible_geometry_pixels": visible,
|
||||
"visible_geometry_fraction": visible / total}
|
||||
|
||||
|
||||
def changed_pixel_count(before, after):
|
||||
before_width, before_height, before_rgb = decode_png(before)
|
||||
after_width, after_height, after_rgb = decode_png(after)
|
||||
if (before_width, before_height) != (after_width, after_height):
|
||||
raise RuntimeError(f"capture dimensions differ: {before} / {after}")
|
||||
return sum(before_rgb[offset] != after_rgb[offset]
|
||||
or before_rgb[offset + 1] != after_rgb[offset + 1]
|
||||
or before_rgb[offset + 2] != after_rgb[offset + 2]
|
||||
for offset in range(0, len(before_rgb), 3))
|
||||
|
||||
|
||||
def mesh_triangle_count(mesh):
|
||||
with mesh.open("rb") as stream:
|
||||
magic = stream.read(8)
|
||||
has_materials = magic == b"VFMSH002"
|
||||
if magic not in (b"VFMSH001", b"VFMSH002"):
|
||||
raise RuntimeError(f"unsupported mesh scene header: {mesh}")
|
||||
version, _, tile_count = struct.unpack("<3I", stream.read(12))
|
||||
if version != (2 if has_materials else 1):
|
||||
raise RuntimeError(f"mesh scene version mismatch: {mesh}")
|
||||
stream.seek(40, 1)
|
||||
triangles = 0
|
||||
for _ in range(tile_count):
|
||||
stream.seek(16, 1)
|
||||
_, _, vertex_count, index_count, mask_count = struct.unpack(
|
||||
"<5I", stream.read(20))
|
||||
triangles += index_count // 3
|
||||
stream.seek(vertex_count * 24 + (10 * vertex_count if has_materials else 0)
|
||||
+ index_count * 4 + mask_count, 1)
|
||||
return triangles
|
||||
|
||||
|
||||
def set_route_station(view, distance):
|
||||
points = ROUTE_POINTS[view["passage"]]
|
||||
length = ROUTE_LENGTHS[view["passage"]]
|
||||
center, radius, _, side = route_frame(points, distance)
|
||||
camera = (center[0] + side[0] * 0.75,
|
||||
center[1] + side[1] * 0.75,
|
||||
center[2] - radius + 1.6)
|
||||
target_pos, target_radius = interpolate(points, min(length, distance + 14.0))
|
||||
target = (target_pos[0] + side[0] * 2.35,
|
||||
target_pos[1] + side[1] * 2.35,
|
||||
target_pos[2] - target_radius + 1.8)
|
||||
yaw, pitch = angles(camera, target)
|
||||
if "route_fraction_requested" not in view:
|
||||
view["route_fraction_requested"] = view["route_fraction"]
|
||||
view["route_requested_focus"] = view["focus"]
|
||||
view["route_fraction"] = distance / length
|
||||
view["route_station_adjustment_m"] = distance - length * view["route_fraction_requested"]
|
||||
view["focus"] = center
|
||||
view["camera"] = camera
|
||||
view["target"] = target
|
||||
view["yaw"], view["pitch"] = yaw, pitch
|
||||
view["clip"] = None
|
||||
|
||||
|
||||
def render_command(view, mesh, image, camera, yaw, pitch):
|
||||
args = [RENDERER, mesh, image,
|
||||
"--size=1600x900", f"--fov-deg={view['fov']:.1f}",
|
||||
f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}",
|
||||
f"--pitch-deg={pitch:.6f}", "--shade=readable", "--headlamp=on",
|
||||
"--height-tint=on", "--tile-edges=off", "--tile-skirts=off",
|
||||
f"--height-center-m={view['focus'][2]:.6f}", "--height-range-m=10",
|
||||
f"--focus-region={fmt((*view['focus'], view['focus_radius']))}"]
|
||||
if view["clip"] is not None:
|
||||
args.append(f"--clip-plane={fmt(view['clip'])}")
|
||||
return args
|
||||
|
||||
|
||||
def export_scene(view, binary, mesh, stem):
|
||||
camera = view["camera"]
|
||||
yaw, pitch = angles(camera, view["target"])
|
||||
export_args = [binary, DATA, "--world-assembler-prototype", "--stream-benchmark",
|
||||
"--stream-pipeline", "--workers=8", "--force-generate", "--clip-radius=3",
|
||||
"--max-clip-level=0", "--max-level=0", "--render-distance-chunks=0",
|
||||
f"--camera={fmt((*camera, yaw, pitch))}", f"--mesh-export={mesh}"]
|
||||
row = run(f"{stem}-export", export_args, headless=True)
|
||||
row["scene_bytes"] = mesh.stat().st_size if mesh.exists() else 0
|
||||
row["scene_triangles"] = mesh_triangle_count(mesh) if row["scene_bytes"] else 0
|
||||
return row
|
||||
|
||||
|
||||
def export_and_render(view, label, binary):
|
||||
safe = view["name"].replace("/", "-")
|
||||
stem = f"{safe}-{label}"
|
||||
mesh = OUT / f"{stem}.vfmesh"
|
||||
image = IMAGE_DIR / f"{safe}-{label}.png"
|
||||
camera = view["camera"]
|
||||
yaw, pitch = angles(camera, view["target"])
|
||||
export_row = export_scene(view, binary, mesh, stem)
|
||||
if export_row["scene_bytes"] <= 0:
|
||||
raise RuntimeError(f"empty mesh scene: {mesh}")
|
||||
if export_row["scene_triangles"] == 0:
|
||||
if label != "before-l100" or view.get("kind") != "mid-tunnel":
|
||||
raise RuntimeError(f"route station exports no surface triangles: {mesh}")
|
||||
points = ROUTE_POINTS[view["passage"]]
|
||||
length = ROUTE_LENGTHS[view["passage"]]
|
||||
requested_fraction = view["route_fraction"]
|
||||
requested_distance = length * requested_fraction
|
||||
found_surface = False
|
||||
for offset in (5.0, -5.0, 10.0, -10.0, 20.0, -20.0, 35.0, -35.0,
|
||||
50.0, -50.0, 75.0, -75.0, 100.0, -100.0, 150.0,
|
||||
-150.0, 200.0, -200.0):
|
||||
distance = max(0.0, min(length, requested_distance + offset))
|
||||
if distance == requested_distance:
|
||||
continue
|
||||
set_route_station(view, distance)
|
||||
adjusted_stem = f"{stem}-station-{view['route_fraction']:.5f}"
|
||||
export_row = export_scene(view, binary, mesh, adjusted_stem)
|
||||
if export_row["scene_triangles"] > 0:
|
||||
view["camera_anchor"] = "route-station-shifted-to-nearby-geometry"
|
||||
found_surface = True
|
||||
break
|
||||
if not found_surface:
|
||||
raise RuntimeError(f"no geometry along nearby route stations: {mesh}")
|
||||
camera = view["camera"]
|
||||
yaw, pitch = angles(camera, view["target"])
|
||||
if label == "before-l100" and view.get("camera_anchor") == "nearest-surface-l100-v1":
|
||||
anchor = nearest_surface_anchor(mesh, view["focus"], tile_search_margin_m=256.0)
|
||||
camera = add(anchor["position_m"], mul(anchor["normal"], 8.0))
|
||||
view["camera"] = camera
|
||||
view["target"] = anchor["position_m"]
|
||||
view["yaw"], view["pitch"] = angles(camera, view["target"])
|
||||
view["clip"] = None
|
||||
view["surface_anchor"] = anchor
|
||||
yaw, pitch = view["yaw"], view["pitch"]
|
||||
render_args = render_command(view, mesh, image, camera, yaw, pitch)
|
||||
try:
|
||||
render_row = run(f"{stem}-render", render_args, headless=True)
|
||||
except RuntimeError:
|
||||
if view["clip"] is None:
|
||||
raise
|
||||
retry_args = [arg for arg in render_args
|
||||
if not (isinstance(arg, str) and arg.startswith("--clip-plane="))]
|
||||
render_row = run(f"{stem}-render-no-clip-retry", retry_args, headless=True)
|
||||
render_row["clip_fallback"] = True
|
||||
render_row["image"] = str(image.relative_to(ROOT))
|
||||
render_row["image_bytes"] = image.stat().st_size if image.exists() else 0
|
||||
if render_row["image_bytes"] <= 0:
|
||||
raise RuntimeError(f"empty image: {image}")
|
||||
geometry = image_geometry_metrics(image)
|
||||
if label == "before-l100" and (
|
||||
geometry["visible_geometry_pixels"] < 3000 or geometry["unique_rgb"] < 2):
|
||||
# At a few deep route stations the default forward view looks into empty
|
||||
# sky. Keep the camera on the route station and aim at the nearest L98
|
||||
# surface in the exported scene; retain that matched target for L101.
|
||||
anchor = nearest_surface_anchor(mesh, view["focus"], tile_search_margin_m=256.0)
|
||||
if anchor["distance_m"] > 60.0:
|
||||
raise RuntimeError(f"no nearby surface for fallback camera: {image} {anchor}")
|
||||
camera = view["camera"]
|
||||
target = anchor["position_m"]
|
||||
yaw, pitch = angles(camera, target)
|
||||
view["target"] = target
|
||||
view["yaw"], view["pitch"] = yaw, pitch
|
||||
view["clip"] = None
|
||||
view["camera_anchor"] = "route-station-nearest-surface-target"
|
||||
view["surface_anchor"] = anchor
|
||||
retry_args = render_command(view, mesh, image, camera, yaw, pitch)
|
||||
render_row = run(f"{stem}-render-surface-fallback", retry_args, headless=True)
|
||||
render_row["image"] = str(image.relative_to(ROOT))
|
||||
render_row["image_bytes"] = image.stat().st_size if image.exists() else 0
|
||||
geometry = image_geometry_metrics(image)
|
||||
render_row["geometry"] = geometry
|
||||
if geometry["visible_geometry_pixels"] < 3000 or geometry["unique_rgb"] < 2:
|
||||
raise RuntimeError(f"camera sees sky only or too little geometry: {image} {geometry}")
|
||||
mesh.unlink()
|
||||
return export_row, render_row
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for path in (*BINARIES.values(), RENDERER, DATA, LAYOUT_PATH):
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"missing input: {path}")
|
||||
layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8"))
|
||||
passages, routes = make_passages(layout)
|
||||
ROUTE_POINTS.clear()
|
||||
ROUTE_LENGTHS.clear()
|
||||
for stable_id, points in routes:
|
||||
ROUTE_POINTS[stable_id] = points
|
||||
ROUTE_LENGTHS[stable_id] = route_length(points)
|
||||
views = [view for passage in passages for view in passage["views"]]
|
||||
partial_path = OUT / "runs-partial.json"
|
||||
existing_records = {}
|
||||
if partial_path.is_file():
|
||||
for item in json.loads(partial_path.read_text(encoding="utf-8")):
|
||||
existing_records[item["view"]["name"]] = item
|
||||
records = []
|
||||
for view in views:
|
||||
prior = existing_records.get(view["name"])
|
||||
camera_matches = (prior is not None and
|
||||
prior["view"].get("camera_anchor") == view.get("camera_anchor"))
|
||||
if camera_matches:
|
||||
for key in ("camera", "target", "yaw", "pitch", "clip", "surface_anchor"):
|
||||
if key in prior["view"]:
|
||||
view[key] = prior["view"][key]
|
||||
if camera_matches and all(version in prior and
|
||||
(ROOT / prior[version]["render"]["image"]).is_file()
|
||||
for version in BINARIES):
|
||||
records.append(prior)
|
||||
print(f"[L101CaptureSkip] complete_pair={view['name']}", flush=True)
|
||||
continue
|
||||
pair = {"view": view}
|
||||
for label, binary in BINARIES.items():
|
||||
export_row, render_row = export_and_render(view, label, binary)
|
||||
pair[label] = {"export": export_row, "render": render_row}
|
||||
(OUT / "runs-partial.json").write_text(json.dumps(records + [pair], indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
pair["comparison"] = {"changed_pixels": changed_pixel_count(
|
||||
ROOT / pair["before-l100"]["render"]["image"],
|
||||
ROOT / pair["after-l101"]["render"]["image"])}
|
||||
(OUT / "runs-partial.json").write_text(json.dumps(records + [pair], indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
records.append(pair)
|
||||
summary = {"source_layout_identity": layout["stable_identity"],
|
||||
"view_count": len(views), "paired_png_count": len(views) * 2,
|
||||
"passages": passages, "views": records}
|
||||
(OUT / "summary.json").write_text(json.dumps(summary, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print("[L101CaptureSummary] " + json.dumps({
|
||||
"views": len(views), "paired_png_count": len(views) * 2,
|
||||
"images": [row[version]["render"]["image"] for row in records
|
||||
for version in BINARIES]}, sort_keys=True), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user