#!/usr/bin/env python3 """Measure exported passage-floor meshes along the first 12 m at each mouth.""" import csv import importlib.util import json import math from pathlib import Path ROOT = Path(__file__).resolve().parents[4] OUT = ROOT / "Saved/Linux/l35" RESEARCH = Path(__file__).resolve().parent RING_AUDIT = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py" SPEC = importlib.util.spec_from_file_location("ring_profile_l32", RING_AUDIT) PROFILE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(PROFILE) Q_TO_M = 1.0 / 256.0 SPACING = 0.125 PLATEAU = 0.50 EPSILON = 0.02 STEP = 0.10 SCENES = ( ("owner-fi", "layout-owner-fi.json", "FloatingIslands", False), ("owner-sw", "layout-owner-sw.json", "SurfaceWorld", True), ("upper-sw-fixture", "layout-upper-sw-fixture.json", "SurfaceWorld", False), ) def dist(a, b): return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) def interpolate(path, distance): remaining = max(0.0, distance) for a, b in zip(path, path[1:]): length = dist(a[0], b[0]) if length <= 1.0e-9: continue if remaining <= length: t = remaining / length p = tuple(a[0][axis] + (b[0][axis] - a[0][axis]) * t for axis in range(3)) return p, a[1] + (b[1] - a[1]) * t remaining -= length return path[-1] def make_inward_path(layout, passage, reverse): scale = layout["core_voxel_size_um"] / (256.0 * 1_000_000.0) controls = [(tuple(value * scale for value in point["position_q"]), point["radius_q"] * scale) for point in passage["control_points"]] lower = tuple(value * scale for value in passage["lower_mouth_q"]) lower_index = min(range(len(controls)), key=lambda i: dist(controls[i][0], lower)) path = controls[:lower_index + 1] if reverse: path.reverse() return path def select_passage(layout, generator, prefer_lower): strata = {int(value["strate_index"]): value for value in layout["strates"]} preferred_side = "lower" if prefer_lower else "upper" preferred_candidates = [] fallback_candidates = [] for passage in layout["passages"]: upper = strata[int(passage["upper_strate_index"])]["generator_kind"] lower = strata[int(passage["lower_strate_index"])]["generator_kind"] sides = [] if upper == generator: sides.append("upper") if lower == generator: sides.append("lower") if sides: if preferred_side in sides: preferred_candidates.append((passage, preferred_side == "lower")) else: fallback_candidates.append((passage, sides[0] == "lower")) candidates = preferred_candidates or fallback_candidates if not candidates: raise RuntimeError(f"no route for {generator}") return sorted(candidates, key=lambda item: item[0]["stable_id"])[0] def surface_candidates(buckets, x, y, cell_size, preferred_height): hits = [] for verts in buckets.get((math.floor(x / cell_size), math.floor(y / cell_size)), ()): (a, na), (b, nb), (c, nc) = verts denominator = (b[1] - c[1]) * (a[0] - c[0]) + \ (c[1] - b[1]) * (a[0] - c[0]) if abs(denominator) < 1.0e-12: continue u = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / denominator v = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / denominator w = 1.0 - u - v if min(u, v, w) < -1.0e-6: continue normal_z = u * na[2] + v * nb[2] + w * nc[2] if normal_z <= 0.0: continue z = u * a[2] + v * b[2] + w * c[2] if abs(z - preferred_height) <= 1.0: hits.append((z, normal_z)) unique = {} for z, normal_z in hits: key = round(z, 4) unique[key] = max(unique.get(key, 0.0), normal_z) return [(z, round(normal_z, 3)) for z, normal_z in sorted(unique.items(), key=lambda item: abs(item[0] - preferred_height))[:12]] def sample_profile(mesh_path, path): total = sum(dist(a[0], b[0]) for a, b in zip(path, path[1:])) reach = min(12.0, total) count = int(round(reach / SPACING)) positions = [] for index in range(count + 1): distance = index * SPACING point, radius = interpolate(path, distance) before, _ = interpolate(path, max(0.0, distance - 0.25)) after, _ = interpolate(path, min(total, distance + 0.25)) tangent = tuple(after[axis] - before[axis] for axis in range(3)) length = math.hypot(tangent[0], tangent[1]) if length <= 1.0e-8: positions.append((distance, point, radius, None)) else: side = (-tangent[1] / length, tangent[0] / length) positions.append((distance, point, radius, side)) expected = [point[2] - radius for _, point, radius, _ in positions] bounds = (min(value[1][0] for value in positions) - 14.0, max(value[1][0] for value in positions) + 14.0, min(value[1][1] for value in positions) - 14.0, max(value[1][1] for value in positions) + 14.0) voxel_um, triangles, selected = PROFILE.read_scene( str(mesh_path), None, min(expected) - 2.0, max(expected) + 2.0, bounds) cell_size = max(2.0, 16.0 * voxel_um / 1_000_000.0) buckets = PROFILE.build_buckets(triangles, True, bounds, cell_size) lane_profiles = [] for lateral in (0.0, 0.75, -0.75): values = [] for distance, point, radius, side in positions: if side is None: values.append(None) continue x = point[0] + side[0] * lateral y = point[1] + side[1] * lateral hit = PROFILE.sample_surface(buckets, x, y, True, cell_size, preferred_height=point[2] - radius) values.append((distance, hit[0]) if hit else None) lane_profiles.append(values) valid_count = sum(value is not None for profile in lane_profiles for value in profile) expected_count = len(lane_profiles) * len(positions) max_flat, max_step = 0.0, 0.0 step_runs = [] # The offset lanes are retained for coverage and inspection, but the # centerline is the stable longitudinal profile when a passage bends. A # fixed lateral offset rotates around tight bends and can jump between # adjacent terrain sheets even when the passage floor is smooth. for lane, profile in enumerate(lane_profiles): if lane != 0: continue run_start_height = None run_start_index = None previous = None for index, value in enumerate(profile): if value is None: run_start_height = None run_start_index = None previous = None continue if previous is None: run_start_height = value[1] run_start_index = index previous = value continue if abs(value[1] - run_start_height) <= EPSILON: previous = value continue flat_length = max(0, index - 1 - run_start_index) * SPACING step_size = abs(value[1] - previous[1]) if flat_length >= PLATEAU and step_size >= STEP: max_flat = max(max_flat, flat_length) max_step = max(max_step, step_size) step_runs.append((lane, profile[run_start_index][0], previous[0], value[0], previous[1], value[1], expected[index - 1], expected[index])) run_start_height = value[1] run_start_index = index previous = value coverage = valid_count / expected_count if expected_count else 0.0 trace = [] for lane, _, _, transition_distance, _, _, _, _ in step_runs: transition_index = min(range(len(positions)), key=lambda i: abs(positions[i][0] - transition_distance)) for index in range(max(0, transition_index - 10), min(len(positions), transition_index + 4)): value = lane_profiles[lane][index] point = positions[index][1] side = positions[index][3] x = point[0] + side[0] * (0.75 if lane == 1 else -0.75 if lane == 2 else 0.0) y = point[1] + side[1] * (0.75 if lane == 1 else -0.75 if lane == 2 else 0.0) trace.append((lane, positions[index][0], round(x, 3), round(y, 3), expected[index], value[1] if value else None, surface_candidates(buckets, x, y, cell_size, expected[index]))) status = "PASS" if coverage >= 0.75 and max_flat == 0.0 else \ "NO_COVERAGE" if coverage < 0.75 else "FAIL" return { "mesh": mesh_path.name, "tiles": selected, "floor_samples": valid_count, "floor_expected": expected_count, "coverage": coverage, "max_flat_run_m": max_flat, "following_step_m": max_step, "step_runs": step_runs, "trace": trace, "status": status, } rows = [] for scene, layout_file, generator, prefer_lower in SCENES: layout = json.loads((OUT / layout_file).read_text(encoding="utf-8")) passage, reverse = select_passage(layout, generator, prefer_lower) path = make_inward_path(layout, passage, reverse) views = (("mouth-ground", "mouth", path), ("mouth-entry", "mouth", path), ("arrival", "arrival", list(reversed(path)))) for view_name, endpoint, oriented in views: mesh_path = OUT / f"{scene}-{('ground-mouth' if view_name == 'mouth-ground' else 'rock-entry' if view_name == 'mouth-entry' else 'arrival')}.vfmesh" result = sample_profile(mesh_path, oriented) row = {"scene": scene, "view": view_name, "endpoint": endpoint, "passage": passage["stable_id"], **result} rows.append(row) print(f"[L35MouthMeshProfile] scene={scene} view={view_name} endpoint={endpoint} " f"passage={passage['stable_id']} mesh={result['mesh']} " f"tiles={result['tiles']} floor_samples={result['floor_samples']}/" f"{result['floor_expected']} coverage={result['coverage']:.3f} " f"flat_run_m={result['max_flat_run_m']:.3f} " f"following_step_m={result['following_step_m']:.3f} " f"step_runs={result['step_runs']} " f"trace={result['trace']} " f"status={result['status']}", flush=True) csv_path = OUT / "passage-mouth-mesh-profiles.csv" with csv_path.open("w", newline="", encoding="utf-8") as stream: writer = csv.DictWriter(stream, fieldnames=list(rows[0])) writer.writeheader() writer.writerows(rows) print(f"[L35MouthMeshProfileSummary] profiles={len(rows)} " f"pass={sum(row['status'] == 'PASS' for row in rows)} " f"no_coverage={sum(row['status'] == 'NO_COVERAGE' for row in rows)} " f"fail={sum(row['status'] == 'FAIL' for row in rows)} file={csv_path}", flush=True)