The walk tread was built per segment as a one-sided density clamp, so its crossing with the native field moved at segment joins (L35: 2.25-2.48 cm washboard). Treads now overlap up to 2 m as bounded signed-distance slabs (0.06 cm residual). The FI mouth's crater detail and tread met without a smooth join; v23 filters the local field, drops thin isolated solids and smooth-unions the tread with the terrain. New floor ripple and mouth fragment/crease audits with L35 as failing controls. 24/24 passage rows, 30/30 determinism, 16/16 invariants, LOD0-8 rings; cost within noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX
159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure the exported walk surface along the passage under the tunnel camera."""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[4]
|
|
RING_PATH = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py"
|
|
SPEC = importlib.util.spec_from_file_location("ring_profile", RING_PATH)
|
|
RING = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(RING)
|
|
CAMERA_RE = re.compile(r"^\[L34CaptureCamera\] view=(\S+).*?camera_m=\(([^)]*)\) "
|
|
r"target_m=\(([^)]*)\)", re.M)
|
|
|
|
|
|
def length(a, b):
|
|
return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
|
|
|
|
|
|
def fit(values, spacing):
|
|
best = (0.0, 0.0)
|
|
for step in range(8, 129):
|
|
period = step * 0.0625
|
|
omega = 2.0 * math.pi / period
|
|
cc = ss = cs = yc = ys = 0.0
|
|
for i, value in enumerate(values):
|
|
phase = omega * i * spacing
|
|
c, s = math.cos(phase), math.sin(phase)
|
|
cc += c * c
|
|
ss += s * s
|
|
cs += c * s
|
|
yc += value * c
|
|
ys += value * s
|
|
determinant = cc * ss - cs * cs
|
|
if determinant <= 1.0e-9:
|
|
continue
|
|
cosine = (yc * ss - ys * cs) / determinant
|
|
sine = (ys * cc - yc * cs) / determinant
|
|
amplitude = math.hypot(cosine, sine)
|
|
if amplitude > best[1]:
|
|
best = period, amplitude
|
|
return best
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("mesh")
|
|
parser.add_argument("layout")
|
|
parser.add_argument("--view", default="owner-sw-mid-tunnel")
|
|
parser.add_argument("--passage", default="passage/0-1/0")
|
|
parser.add_argument("--window", type=float, default=32.0)
|
|
parser.add_argument("--spacing", type=float, default=0.125)
|
|
parser.add_argument("--amplitude-limit-cm", type=float, default=1.5)
|
|
args = parser.parse_args()
|
|
log = ROOT / "Saved/Linux/l34/run-capture-standard-set-final-radius3.log"
|
|
cameras = {name: (tuple(map(float, camera.split(","))),
|
|
tuple(map(float, target.split(","))))
|
|
for name, camera, target in CAMERA_RE.findall(
|
|
log.read_text(encoding="utf-8", errors="replace"))}
|
|
if args.view not in cameras:
|
|
raise SystemExit(f"missing camera target for {args.view}")
|
|
camera, target = cameras[args.view]
|
|
layout = json.loads(Path(args.layout).read_text(encoding="utf-8"))
|
|
scale = layout["core_voxel_size_um"] / (256.0 * 1_000_000.0)
|
|
passage = next(p for p in layout["passages"] if p["stable_id"] == args.passage)
|
|
controls = [(tuple(v * scale for v in point["position_q"]),
|
|
point["radius_q"] * scale) for point in passage["control_points"]]
|
|
lower_mouth = tuple(v * scale for v in passage["lower_mouth_q"])
|
|
end = min(range(len(controls)), key=lambda i: length(controls[i][0], lower_mouth))
|
|
controls = controls[:end + 1]
|
|
path = []
|
|
distance = 0.0
|
|
for point in controls:
|
|
if path:
|
|
distance += length(path[-1][0], point[0])
|
|
path.append((point[0], point[1], distance))
|
|
closest = None
|
|
for index, (a, b) in enumerate(zip(path, path[1:])):
|
|
delta = tuple(b[0][axis] - a[0][axis] for axis in range(3))
|
|
sq = sum(v * v for v in delta)
|
|
t = max(0.0, min(1.0, sum((target[k] - a[0][k]) * delta[k]
|
|
for k in range(3)) / sq)) if sq else 0.0
|
|
point = tuple(a[0][k] + t * delta[k] for k in range(3))
|
|
d2 = sum((point[k] - target[k]) ** 2 for k in range(3))
|
|
if closest is None or d2 < closest[0]:
|
|
closest = (d2, a[2] + t * (b[2] - a[2]), index, t)
|
|
_, center_distance, segment, segment_t = closest
|
|
|
|
def interpolate(distance_value):
|
|
distance_value = max(0.0, min(path[-1][2], distance_value))
|
|
for a, b in zip(path, path[1:]):
|
|
if distance_value <= b[2]:
|
|
t = (distance_value - a[2]) / max(1.0e-9, b[2] - a[2])
|
|
point = tuple(a[0][k] + t * (b[0][k] - a[0][k]) for k in range(3))
|
|
radius = a[1] + t * (b[1] - a[1])
|
|
return point, radius
|
|
return path[-1][0], path[-1][1]
|
|
|
|
half = args.window * 0.5
|
|
start = max(0.0, center_distance - half)
|
|
finish = min(path[-1][2], center_distance + half)
|
|
count = int((finish - start) / args.spacing) + 1
|
|
positions = []
|
|
expected = []
|
|
for i in range(count):
|
|
route_distance = start + i * args.spacing
|
|
point, radius = interpolate(route_distance)
|
|
positions.append((route_distance - center_distance, point))
|
|
expected.append(point[2] - radius + 0.20)
|
|
bounds = (min(p[0] for _, p in positions) - 8.0,
|
|
max(p[0] for _, p in positions) + 8.0,
|
|
min(p[1] for _, p in positions) - 8.0,
|
|
max(p[1] for _, p in positions) + 8.0)
|
|
zmin, zmax = min(expected) - 1.5, max(expected) + 1.5
|
|
voxel_um, triangles, tiles = RING.read_scene(args.mesh, 0, zmin, zmax, bounds)
|
|
bucket_size = max(2.0, 16.0 * voxel_um / 1_000_000.0)
|
|
buckets = RING.build_buckets(triangles, True, bounds, bucket_size)
|
|
values = []
|
|
for (_, point), preferred in zip(positions, expected):
|
|
hit = RING.sample_surface(buckets, point[0], point[1], True,
|
|
bucket_size, preferred)
|
|
values.append(hit[0] if hit else None)
|
|
valid = [(index, z) for index, z in enumerate(values) if z is not None]
|
|
if len(valid) < 0.8 * len(values):
|
|
raise SystemExit(f"insufficient mesh floor coverage {len(valid)}/{len(values)}")
|
|
residual = [values[i] - expected[i] for i in range(len(values))
|
|
if values[i] is not None]
|
|
mean = sum(residual) / len(residual)
|
|
period, amplitude = fit([v - mean for v in residual], args.spacing)
|
|
adjacent = [abs(values[i] - values[i - 1]) for i in range(1, len(values))
|
|
if values[i] is not None and values[i - 1] is not None]
|
|
route_segment_length = path[segment + 1][2] - path[segment][2]
|
|
print(f"[WalkFloorProfileProbe] mesh={args.mesh} view={args.view} voxel_um={voxel_um} "
|
|
f"tiles={tiles} samples={len(valid)}/{len(values)} window_m={finish-start:.3f} "
|
|
f"camera_target_m=({target[0]:.3f},{target[1]:.3f},{target[2]:.3f}) "
|
|
f"route_distance_m={center_distance:.3f} nearest_segment={segment} "
|
|
f"segment_t={segment_t:.3f} segment_length_m={route_segment_length:.3f} "
|
|
f"floor_expected_range_m={min(expected):.3f}..{max(expected):.3f} "
|
|
f"residual_range_m={min(residual):.4f}..{max(residual):.4f} "
|
|
f"period_m={period:.3f} amplitude_m={amplitude:.5f} "
|
|
f"amplitude_cm={amplitude*100:.3f} adjacent_step_max_m="
|
|
f"{max(adjacent, default=0.0):.4f} limit_cm={args.amplitude_limit_cm:.3f} "
|
|
f"status={'PASS' if amplitude*100 <= args.amplitude_limit_cm else 'FAIL'}")
|
|
out = Path(args.mesh).with_suffix(".walk-floor-profile.csv")
|
|
with out.open("w", encoding="utf-8") as stream:
|
|
stream.write("distance_m,route_x_m,route_y_m,expected_floor_m,mesh_floor_m,residual_m\n")
|
|
for i, (distance_m, point) in enumerate(positions):
|
|
z = values[i]
|
|
stream.write(f"{distance_m:.3f},{point[0]:.6f},{point[1]:.6f},"
|
|
f"{expected[i]:.6f},{'' if z is None else f'{z:.6f}'},"
|
|
f"{'' if z is None else f'{z-expected[i]:.6f}'}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|