#!/usr/bin/env python3 """Structural mouth continuity and forward-extension checks for L149 layouts.""" from __future__ import annotations import json import math from pathlib import Path from typing import Any def _q_to_um(value: int, core_voxel_size_um: int) -> int: return int(value * core_voxel_size_um / 256) def _mouth(layout: dict[str, Any], edge: dict[str, Any]) -> tuple[dict[str, Any], int, list[int], int] | None: anchor = edge.get("start_anchor_stable_id", "") if not anchor.endswith(("/lower-mouth", "/upper-mouth")): return None side = anchor.rsplit("/", 1)[1] passage_id = anchor.rsplit("/", 1)[0] passage = next((row for row in layout.get("passages", []) if row.get("stable_id") == passage_id), None) if passage is None: return None key = "lower_mouth_q" if side == "lower-mouth" else "upper_mouth_q" mouth_q = passage.get(key) controls = passage.get("control_points", []) if not mouth_q or not controls: return None if side == "upper-mouth": index = 0 else: center_height_q = round(1.13 * 256_000_000 / layout["core_voxel_size_um"]) matches = [i for i, point in enumerate(controls) if point["position_q"][0] == mouth_q[0] and point["position_q"][1] == mouth_q[1] and point["position_q"][2] - point["radius_q"] + center_height_q == mouth_q[2]] if not matches: return None index = matches[-1] return passage, index, mouth_q, _q_to_um(controls[index]["radius_q"], layout["core_voxel_size_um"]) def _dot_direction(a: list[float], b: list[float]) -> float: al, bl = math.sqrt(sum(x * x for x in a)), math.sqrt(sum(x * x for x in b)) return sum(x * y for x, y in zip(a, b)) / (al * bl) if al > 0 and bl > 0 else -1.0 def _bezier(points: list[list[int]], t: float) -> list[float]: u = 1.0 - t return [u**3 * points[0][axis] + 3*u*u*t * points[1][axis] + 3*u*t*t * points[2][axis] + t**3 * points[3][axis] for axis in range(3)] def _metrics(layout: dict[str, Any], edge: dict[str, Any]) -> dict[str, Any]: found = _mouth(layout, edge) if found is None: return {"bound": False, "tangent_dot": -1.0, "radius_match": False, "position_match": False, "floor_slope_delta": math.inf, "forward_extension": False} passage, index, _, expected_radius = found controls = passage["control_points"] route = edge.get("control_points_um", []) if len(route) != 4 or len(controls) < 2: return {"bound": False, "tangent_dot": -1.0, "radius_match": False, "position_match": False, "floor_slope_delta": math.inf, "forward_extension": False} side = edge["start_anchor_stable_id"].rsplit("/", 1)[1] neighbor = 1 if side == "upper-mouth" else index - 1 if neighbor < 0 or neighbor >= len(controls): return {"bound": False, "tangent_dot": -1.0, "radius_match": False, "position_match": False, "floor_slope_delta": math.inf, "forward_extension": False} mouth_point = controls[index] neighbor_point = controls[neighbor] core = layout["core_voxel_size_um"] expected = [_q_to_um(v, core) for v in mouth_point["position_q"]] expected[2] -= expected_radius expected_dir = [_q_to_um(mouth_point["position_q"][axis] - neighbor_point["position_q"][axis], core) for axis in range(2)] edge_dir = [route[1][0] - route[0][0], route[1][1] - route[0][1]] position_match = all(abs(route[0][axis] - expected[axis]) <= 2 for axis in range(3)) radius_match = edge.get("bore_radius_um") == expected_radius tangent_dot = _dot_direction([*edge_dir, 0.0], [*expected_dir, 0.0]) floor_mouth = mouth_point["position_q"][2] - mouth_point["radius_q"] floor_neighbor = neighbor_point["position_q"][2] - neighbor_point["radius_q"] expected_slope = _q_to_um(floor_mouth - floor_neighbor, core) / max( 1.0, math.hypot(*expected_dir)) if side == "upper-mouth": expected_slope = -expected_slope route_slope = (route[1][2] - route[0][2]) / max(1.0, math.hypot(*edge_dir)) floor_slope_delta = abs(route_slope - expected_slope) tangent_length = math.hypot(*expected_dir) tangent = [v / tangent_length for v in expected_dir] if tangent_length else [0.0, 0.0] points = [list(p) for p in route] previous = _bezier(points, 0.0) distance = 0.0 prior_progress = 0.0 forward = True for step in range(1, 1025): current = _bezier(points, step / 1024.0) dx, dy = current[0] - previous[0], current[1] - previous[1] segment = math.hypot(dx, dy) next_distance = distance + segment if distance < 12_000_000: progress = ((current[0] - route[0][0]) * tangent[0] + (current[1] - route[0][1]) * tangent[1]) if progress < -100_000 or progress < prior_progress - 100_000: forward = False prior_progress = progress previous, distance = current, next_distance if distance >= 12_000_000: break return {"bound": True, "tangent_dot": tangent_dot, "radius_match": radius_match, "position_match": position_match, "floor_slope_delta": floor_slope_delta, "forward_extension": forward} def audit_layout(layout_path: Path) -> dict[str, Any]: layout = json.loads(layout_path.read_text(encoding="utf-8")) mouth_edges = [edge for edge in layout.get("cavern_edges", []) if edge.get("strate_index") in (2, 3) and edge.get("stable_id", "").endswith(("route-edge-0", "departure-edge")) and edge.get("start_anchor_stable_id", "").endswith( ("/lower-mouth", "/upper-mouth"))] rows = [_metrics(layout, edge) for edge in mouth_edges] negative_controls = 0 for edge in mouth_edges: found = _mouth(layout, edge) if found is None: continue passage, index, _, _ = found side = edge["start_anchor_stable_id"].rsplit("/", 1)[1] neighbor = 1 if side == "upper-mouth" else index - 1 mouth_point = passage["control_points"][index] neighbor_point = passage["control_points"][neighbor] core = layout["core_voxel_size_um"] expected_dir = [_q_to_um(mouth_point["position_q"][axis] - neighbor_point["position_q"][axis], core) for axis in range(2)] altered = dict(edge) points = [list(p) for p in edge["control_points_um"]] length = max(1.0, math.hypot(*expected_dir)) points[1][0] = points[0][0] - round(expected_dir[0] / length * 1_000_000) points[1][1] = points[0][1] - round(expected_dir[1] / length * 1_000_000) altered["control_points_um"] = points control = _metrics(layout, altered) if control["tangent_dot"] < 0.9: negative_controls += 1 continuity = bool(rows) and all( row["bound"] and row["position_match"] and row["radius_match"] and row["tangent_dot"] >= 0.9 and row["floor_slope_delta"] <= 0.15 for row in rows) no_backtrack = bool(rows) and all(row["forward_extension"] for row in rows) controls_ok = bool(mouth_edges) and negative_controls == len(mouth_edges) return {"edges": len(mouth_edges), "continuity": continuity, "no_backtrack_12m": no_backtrack, "negative_controls": negative_controls, "negative_controls_expected": len(mouth_edges), "negative_controls_pass": controls_ok, "minimum_tangent_dot": min((row["tangent_dot"] for row in rows), default=-1.0), "maximum_floor_slope_delta": max( (row["floor_slope_delta"] for row in rows), default=math.inf), "rows": rows}