#!/usr/bin/env python3 """Measure player-scale planar aprons, straight boundaries and corners at passage mouths.""" import argparse import importlib.util import math from collections import defaultdict, deque from pathlib import Path ROOT = Path(__file__).resolve().parents[4] PROFILE_PATH = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py" SPEC = importlib.util.spec_from_file_location("ring_profile_l37", PROFILE_PATH) PROFILE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(PROFILE) # Frozen from player scale before measuring the L37 candidate. The 0.75 m line limit # is just over the 0.68 m capsule width; 0.50 m2 is one capsule-width footprint. STRAIGHT_LIMIT_M = 0.75 PLANE_LIMIT_M2 = 0.50 CORNER_TURN_LIMIT_DEG = 30.0 PLANE_TOLERANCE_M = 0.02 NORMAL_BUCKET = 0.07 PLANE_BUCKET_M = 0.03 MOUTH_CORRIDOR_HALF_WIDTH_M = 1.50 MOUTH_CORRIDOR_LENGTH_M = 12.0 MOUTH_Z_BELOW_M = 4.0 MOUTH_Z_ABOVE_M = 3.0 PLAYER_FLOOR_OFFSET_M = 1.13 DELTA_THRESHOLD_M = 0.05 DELTA_GRID_M = 0.125 SCENES = { "owner-fi": ("owner-fi-ground-mouth.vfmesh", "layout-owner-fi.json", "FloatingIslands", "upper"), "owner-sw": ("owner-sw-ground-mouth.vfmesh", "layout-owner-sw.json", "SurfaceWorld", "lower"), "style-override-upper-sw-fixture-style0": ( "style-override-upper-sw-fixture-style0-ground-mouth.vfmesh", "layout-style-override-upper-sw-fixture-style0.json", "SurfaceWorld", "upper"), } def sub(a, b): return tuple(a[i] - b[i] for i in range(3)) def dot(a, b): return sum(a[i] * b[i] for i in range(3)) def cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) def length(a): return math.sqrt(dot(a, a)) def face(points): n = cross(sub(points[1], points[0]), sub(points[2], points[0])) twice_area = length(n) if twice_area <= 1.0e-12: return None area = twice_area * 0.5 n = tuple(v / twice_area for v in n) if n[2] < 0.0: n = tuple(-v for v in n) d = -dot(n, points[0]) return n, d, area def vertex_key(point): return tuple(round(value, 4) for value in point) def edge_key(a, b): ka, kb = vertex_key(a), vertex_key(b) return (ka, kb) if ka < kb else (kb, ka) def rdp(points, epsilon): if len(points) <= 2: return points a, b = points[0], points[-1] ab = sub(b, a) ab2 = dot(ab, ab) maximum, index = -1.0, -1 for i in range(1, len(points) - 1): ap = sub(points[i], a) t = max(0.0, min(1.0, dot(ap, ab) / ab2)) if ab2 else 0.0 closest = tuple(a[j] + t * ab[j] for j in range(3)) distance = length(sub(points[i], closest)) if distance > maximum: maximum, index = distance, i if maximum <= epsilon: return [a, b] left = rdp(points[:index + 1], epsilon) right = rdp(points[index:], epsilon) return left[:-1] + right def simplified_boundary(component, records): counts = defaultdict(int) actual_edges = {} for index in component: points = records[index]["points"] for a, b in ((points[0], points[1]), (points[1], points[2]), (points[2], points[0])): key = edge_key(a, b) counts[key] += 1 actual_edges[key] = (key[0], key[1]) boundary = [edge for edge, count in counts.items() if count == 1] graph = defaultdict(list) for a, b in boundary: graph[a].append(b) graph[b].append(a) if not graph: return [], [] def direction(a, b): dx, dy = b[0] - a[0], b[1] - a[1] magnitude = math.hypot(dx, dy) return ((dx / magnitude, dy / magnitude), magnitude) if magnitude > 1.0e-8 else (None, 0.0) def straight_run(origin, first): prior, current = origin, first initial, edge_length = direction(prior, current) if initial is None: return 0.0 total = edge_length seen = {origin, first} while current in graph and len(graph[current]) == 2: choices = [value for value in graph[current] if value != prior] if len(choices) != 1 or choices[0] in seen: break nxt = choices[0] outgoing, edge_length = direction(current, nxt) if outgoing is None: break deflection = math.degrees(math.acos(max(-1.0, min(1.0, initial[0] * outgoing[0] + initial[1] * outgoing[1])))) if deflection > 8.0: break total += edge_length prior, current = current, nxt initial = outgoing seen.add(current) return total longest = 0.0 corners = [] for vertex, neighbours in graph.items(): if len(neighbours) == 2: incoming, _ = direction(neighbours[0], vertex) outgoing, _ = direction(vertex, neighbours[1]) if incoming and outgoing: turn = math.degrees(math.acos(max(-1.0, min(1.0, incoming[0] * outgoing[0] + incoming[1] * outgoing[1])))) if turn >= CORNER_TURN_LIMIT_DEG: left_run = straight_run(vertex, neighbours[0]) right_run = straight_run(vertex, neighbours[1]) if left_run >= STRAIGHT_LIMIT_M and right_run >= STRAIGHT_LIMIT_M: corners.append(turn) for neighbour in neighbours: longest = max(longest, straight_run(vertex, neighbour)) return [longest], corners def analyze_triangles(triangles, center, center_z, crop_to_center=True, include_sides=False): records = [] for lod, average_nz, vertices in triangles: points = tuple(vertex[0] for vertex in vertices) centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3)) if (crop_to_center and (abs(centroid[0] - center[0]) > MOUTH_CORRIDOR_LENGTH_M + 2.0 or abs(centroid[1] - center[1]) > MOUTH_CORRIDOR_LENGTH_M + 2.0 or centroid[2] < center_z - MOUTH_Z_BELOW_M or centroid[2] > center_z + MOUTH_Z_ABOVE_M)): continue measured = face(points) if not measured: continue normal, plane_d, area = measured if not include_sides and (normal[2] < 0.35 or average_nz < 0.25): continue bucket = (round(normal[0] / NORMAL_BUCKET), round(normal[1] / NORMAL_BUCKET), round(normal[2] / NORMAL_BUCKET), round(plane_d / PLANE_BUCKET_M)) records.append({"points": points, "centroid": centroid, "normal": normal, "d": plane_d, "area": area, "bucket": bucket}) by_bucket = defaultdict(list) for index, record in enumerate(records): by_bucket[record["bucket"]].append(index) edge_indices = defaultdict(list) for index, record in enumerate(records): points = record["points"] for a, b in ((points[0], points[1]), (points[1], points[2]), (points[2], points[0])): edge_indices[edge_key(a, b)].append(index) adjacency = defaultdict(list) for indices in edge_indices.values(): if len(indices) == 2: a, b = indices # Adjacent triangles can differ by one normal bucket on a smooth mesh. # Plane membership is checked against the seed below, so let that test # decide whether a connected patch is planar instead of splitting it at # the bucket boundary. adjacency[a].append(b) adjacency[b].append(a) max_area = 0.0 max_straight = 0.0 corner_count = 0 max_corner_turn = 0.0 max_bounds = None max_normal = None visited = set() for indices in by_bucket.values(): for seed in indices: if seed in visited: continue component = [] queue = deque([seed]) queued = {seed} seed_record = records[seed] while queue: index = queue.popleft() record = records[index] if (abs(dot(seed_record["normal"], record["normal"])) < math.cos(math.radians(4.5)) or max(abs(dot(seed_record["normal"], point) + seed_record["d"]) for point in record["points"]) > PLANE_TOLERANCE_M): continue visited.add(index) component.append(index) for neighbour in adjacency[index]: if neighbour not in visited and neighbour not in queued: queued.add(neighbour) queue.append(neighbour) area = sum(records[index]["area"] for index in component) if area <= max_area: continue segments, corners = simplified_boundary(component, records) max_area = area max_straight = max(segments, default=0.0) corner_count = len(corners) max_corner_turn = max(corners, default=0.0) points = [point for index in component for point in records[index]["points"]] max_bounds = tuple((min(point[axis] for point in points), max(point[axis] for point in points)) for axis in range(3)) max_normal = seed_record["normal"] return {"triangles": len(records), "planar_area": max_area, "straight": max_straight, "corners": corner_count, "corner_turn_max": max_corner_turn, "bounds": max_bounds, "normal": max_normal} def synthetic_box(center, z): x, y = center corners = ((x - 1.5, y - 1.0, z), (x + 1.5, y - 1.0, z), (x + 1.5, y + 1.0, z), (x - 1.5, y + 1.0, z)) return [(0, 1.0, tuple((point, (0.0, 0.0, 1.0)) for point in tri)) for tri in ((corners[0], corners[1], corners[2]), (corners[0], corners[2], corners[3]))] def fail(metrics, include_planarity=True): planar_fail = (include_planarity and (metrics["planar_area"] >= PLANE_LIMIT_M2 or metrics.get("all_face_planar_area", 0.0) >= PLANE_LIMIT_M2)) return (planar_fail or metrics["straight"] > STRAIGHT_LIMIT_M or metrics.get("all_face_straight", 0.0) > STRAIGHT_LIMIT_M or metrics["corners"] > 0 or metrics.get("all_face_corners", 0) > 0) def mouth_route_points(passage, side, scale): controls = [ (tuple(value * scale for value in point["position_q"]), point["radius_q"] * scale) for point in passage["control_points"] ] if side == "upper": return controls[:4] lower_mouth = tuple(value * scale for value in passage["lower_mouth_q"]) lower_index = min(range(len(controls)), key=lambda index: sum( (controls[index][0][axis] - lower_mouth[axis]) ** 2 for axis in range(3))) return list(reversed(controls[max(0, lower_index - 3):lower_index + 1])) def select_corridor_triangles(triangles, route, half_width, path_length): selected = [] for triangle in triangles: vertices = triangle[2] centroid = tuple(sum(vertex[0][axis] for vertex in vertices) / 3.0 for axis in range(3)) closest_distance = math.inf closest_along = 0.0 closest_floor = 0.0 prefix = 0.0 for (a, radius_a), (b, radius_b) in zip(route, route[1:]): dx, dy, dz = (b[axis] - a[axis] for axis in range(3)) horizontal_length_squared = dx * dx + dy * dy horizontal_length = math.sqrt(horizontal_length_squared) if horizontal_length_squared <= 1.0e-12: t = 0.0 else: t = max(0.0, min(1.0, ((centroid[0] - a[0]) * dx + (centroid[1] - a[1]) * dy) / horizontal_length_squared)) center_x, center_y = a[0] + t * dx, a[1] + t * dy lateral = math.hypot(centroid[0] - center_x, centroid[1] - center_y) segment_length = math.sqrt(horizontal_length_squared + dz * dz) if lateral < closest_distance: closest_distance = lateral closest_along = prefix + t * segment_length center_z = a[2] + t * dz radius = radius_a + t * (radius_b - radius_a) closest_floor = center_z - radius + PLAYER_FLOOR_OFFSET_M prefix += segment_length if (closest_distance <= half_width and 0.0 <= closest_along <= path_length and abs(centroid[2] - closest_floor) <= 1.5): selected.append(triangle) return selected def route_floor_at(route, x, y): closest = None prefix = 0.0 for (a, radius_a), (b, radius_b) in zip(route, route[1:]): dx, dy, dz = (b[axis] - a[axis] for axis in range(3)) horizontal_sq = dx * dx + dy * dy t = max(0.0, min(1.0, ((x - a[0]) * dx + (y - a[1]) * dy) / horizontal_sq)) if horizontal_sq > 1.0e-12 else 0.0 center_x, center_y = a[0] + t * dx, a[1] + t * dy lateral = math.hypot(x - center_x, y - center_y) length = math.sqrt(horizontal_sq + dz * dz) along = prefix + t * length center_z = a[2] + t * dz radius = radius_a + t * (radius_b - radius_a) floor = center_z - radius + PLAYER_FLOOR_OFFSET_M candidate = (lateral, along, floor) if closest is None or lateral < closest[0]: closest = candidate prefix += length if closest and closest[0] <= MOUTH_CORRIDOR_HALF_WIDTH_M \ and closest[1] <= MOUTH_CORRIDOR_LENGTH_M: return closest[2] return None def differential_surface(candidate_triangles, baseline_triangles, route, bounds): cell_size = 0.5 candidate_buckets = PROFILE.build_buckets(candidate_triangles, True, bounds, cell_size) baseline_buckets = PROFILE.build_buckets(baseline_triangles, True, bounds, cell_size) route_x = [point[0][0] for point in route] route_y = [point[0][1] for point in route] padding = MOUTH_CORRIDOR_HALF_WIDTH_M + DELTA_GRID_M x0 = math.floor((min(route_x) - padding) / DELTA_GRID_M) * DELTA_GRID_M x1 = math.ceil((max(route_x) + padding) / DELTA_GRID_M) * DELTA_GRID_M y0 = math.floor((min(route_y) - padding) / DELTA_GRID_M) * DELTA_GRID_M y1 = math.ceil((max(route_y) + padding) / DELTA_GRID_M) * DELTA_GRID_M nx = round((x1 - x0) / DELTA_GRID_M) ny = round((y1 - y0) / DELTA_GRID_M) samples = {} for iy in range(ny + 1): y = y0 + iy * DELTA_GRID_M for ix in range(nx + 1): x = x0 + ix * DELTA_GRID_M floor = route_floor_at(route, x, y) if floor is None: continue candidate = PROFILE.sample_surface(candidate_buckets, x, y, True, cell_size, preferred_height=floor) baseline = PROFILE.sample_surface(baseline_buckets, x, y, True, cell_size, preferred_height=floor) samples[(ix, iy)] = (candidate, baseline) delta_triangles = [] changed_cells = 0 changed_area = 0.0 deltas = [] for iy in range(ny): for ix in range(nx): keys = ((ix, iy), (ix + 1, iy), (ix + 1, iy + 1), (ix, iy + 1)) values = [samples.get(key) for key in keys] if any(value is None or value[0] is None for value in values): continue cell_deltas = [] changed = False for candidate, baseline in values: if baseline is None: changed = True cell_deltas.append(math.inf) else: delta = candidate[0] - baseline[0] cell_deltas.append(abs(delta)) deltas.append(abs(delta)) changed |= abs(delta) >= DELTA_THRESHOLD_M if not changed: continue changed_cells += 1 changed_area += DELTA_GRID_M * DELTA_GRID_M points = [] for key, (candidate, _) in zip(keys, values): x = x0 + key[0] * DELTA_GRID_M y = y0 + key[1] * DELTA_GRID_M points.append(((x, y, candidate[0]), candidate[1])) for triangle_indices in ((0, 1, 2), (0, 2, 3)): vertices = tuple(points[index] for index in triangle_indices) avg_nz = sum(vertex[1][2] for vertex in vertices) / 3.0 delta_triangles.append((0, avg_nz, vertices)) metrics = analyze_triangles(delta_triangles, (0.0, 0.0), 0.0, crop_to_center=False) metrics["changed_cells"] = changed_cells metrics["changed_area"] = changed_area metrics["max_delta"] = max(deltas, default=0.0) metrics["mean_abs_delta"] = sum(deltas) / len(deltas) if deltas else 0.0 return metrics def audit_scene(scene, out, baseline_out, mesh_override=None): mesh_name, layout_name, target_generator, side = SCENES[scene] import json layout = json.loads((out / layout_name).read_text(encoding="utf-8")) scale = layout["core_voxel_size_um"] / (256.0 * 1_000_000.0) strata = {int(value["strate_index"]): value for value in layout["strates"]} choices = [] for passage in layout["passages"]: side_index = int(passage[side + "_strate_index"]) if strata[side_index]["generator_kind"] == target_generator: choices.append(passage) if not choices: raise RuntimeError(f"no {target_generator} {side} mouth in {scene}") passage = min(choices, key=lambda value: value["stable_id"]) mouth = tuple(value * scale for value in passage[f"{side}_mouth_q"]) bounds = (mouth[0] - MOUTH_CORRIDOR_LENGTH_M - MOUTH_CORRIDOR_HALF_WIDTH_M - 1.0, mouth[0] + MOUTH_CORRIDOR_LENGTH_M + MOUTH_CORRIDOR_HALF_WIDTH_M + 1.0, mouth[1] - MOUTH_CORRIDOR_LENGTH_M - MOUTH_CORRIDOR_HALF_WIDTH_M - 1.0, mouth[1] + MOUTH_CORRIDOR_LENGTH_M + MOUTH_CORRIDOR_HALF_WIDTH_M + 1.0) voxel_um, triangles, tiles = PROFILE.read_scene( str(mesh_override or out / mesh_name), 0, mouth[2] - MOUTH_Z_BELOW_M, mouth[2] + MOUTH_Z_ABOVE_M, bounds) baseline_um, baseline_triangles, baseline_tiles = PROFILE.read_scene( str(baseline_out / mesh_name), 0, mouth[2] - MOUTH_Z_BELOW_M, mouth[2] + MOUTH_Z_ABOVE_M, bounds) if voxel_um != baseline_um: raise RuntimeError(f"{scene} candidate/control voxel size differs") route = mouth_route_points(passage, side, scale) corridor = select_corridor_triangles(triangles, route, MOUTH_CORRIDOR_HALF_WIDTH_M, MOUTH_CORRIDOR_LENGTH_M) baseline_corridor = select_corridor_triangles(baseline_triangles, route, MOUTH_CORRIDOR_HALF_WIDTH_M, MOUTH_CORRIDOR_LENGTH_M) differential = differential_surface(corridor, baseline_corridor, route, bounds) metrics = analyze_triangles(corridor, mouth[:2], mouth[2], crop_to_center=False) baseline_metrics = analyze_triangles(baseline_corridor, mouth[:2], mouth[2], crop_to_center=False) full_surface = analyze_triangles(corridor, mouth[:2], mouth[2], crop_to_center=False, include_sides=True) proxy_alert = fail(differential, include_planarity=False) corner_gate = (metrics["corners"] == 0 and full_surface["corners"] == 0 and differential["corners"] == 0) metrics["all_face_planar_area"] = full_surface["planar_area"] metrics["all_face_straight"] = full_surface["straight"] metrics["all_face_corners"] = full_surface["corners"] metrics["all_face_corner_turn_max"] = full_surface["corner_turn_max"] print(f"[MouthBuiltShape] scene={scene} mesh={mesh_name} mouth_m=({mouth[0]:.3f}," f"{mouth[1]:.3f},{mouth[2]:.3f}) voxel_um={voxel_um} tiles={tiles} " f"route_corridor_m=({MOUTH_CORRIDOR_HALF_WIDTH_M:.2f}m_halfwidth," f"{MOUTH_CORRIDOR_LENGTH_M:.1f}m_length) corridor_triangles={len(corridor)} " f"surface_triangles={metrics['triangles']} planar_patch_m2={metrics['planar_area']:.4f} " f"straight_run_m={metrics['straight']:.4f} sharp_corners={metrics['corners']} " f"corner_turn_max_deg={metrics['corner_turn_max']:.2f} " f"all_face_planar_patch_m2={full_surface['planar_area']:.4f} " f"all_face_straight_run_m={full_surface['straight']:.4f} " f"all_face_corners={full_surface['corners']} " f"all_face_corner_turn_max_deg={full_surface['corner_turn_max']:.2f} " f"matched_no_support_planar_patch_m2={baseline_metrics['planar_area']:.4f} " f"matched_no_support_straight_run_m={baseline_metrics['straight']:.4f} " f"matched_no_support_corners={baseline_metrics['corners']} " f"control_tiles={baseline_tiles} changed_cells={differential['changed_cells']} " f"changed_area_m2={differential['changed_area']:.4f} " f"max_abs_delta_m={differential['max_delta']:.4f} " f"mean_abs_delta_m={differential['mean_abs_delta']:.4f} " f"added_planar_patch_m2={differential['planar_area']:.4f} " f"added_straight_run_m={differential['straight']:.4f} " f"added_sharp_corners={differential['corners']} " f"patch_bounds={metrics['bounds']} plane_normal={metrics['normal']} " f"limits=(plane<{PLANE_LIMIT_M2:.2f}m2 informational,line<={STRAIGHT_LIMIT_M:.2f}m," f"corner_turn<{CORNER_TURN_LIMIT_DEG:.0f}deg) " f"straight_or_planar_proxy={'ALERT' if proxy_alert else 'PASS'} " f"delta_threshold_m={DELTA_THRESHOLD_M:.2f} status=" f"{'PASS' if corner_gate else 'FAIL'}") metrics["differential"] = differential metrics["matched_no_support"] = baseline_metrics metrics["corner_gate"] = corner_gate metrics["proxy_alert"] = proxy_alert return metrics def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=ROOT / "Saved/Linux/l37") parser.add_argument("--baseline-out", type=Path, default=ROOT / "Saved/Linux/l37/no-tread-control") parser.add_argument("--control-out", type=Path, default=ROOT / "Saved/Linux/l36") parser.add_argument("--scene", choices=tuple(SCENES) + ("all",), default="all") args = parser.parse_args() out = args.out if args.out.is_absolute() else ROOT / args.out baseline_out = (args.baseline_out if args.baseline_out.is_absolute() else ROOT / args.baseline_out) control_out = (args.control_out if args.control_out.is_absolute() else ROOT / args.control_out) scenes = tuple(SCENES) if args.scene == "all" else (args.scene,) measured = {scene: audit_scene(scene, out, baseline_out) for scene in scenes} l36_fi = audit_scene("owner-fi", control_out, baseline_out) l36_failed = fail(l36_fi["differential"]) print(f"[MouthBuiltShapeL36Control] scene=owner-fi planar_patch_m2=" f"{l36_fi['differential']['planar_area']:.4f} " f"straight_run_m={l36_fi['differential']['straight']:.4f} " f"sharp_corners={l36_fi['differential']['corners']} detected={l36_failed} " f"negative_control={'PASS' if l36_failed else 'FAIL'}") control = analyze_triangles(synthetic_box((0.0, 0.0), 0.0), (0.0, 0.0), 0.0) control_ok = fail(control) print(f"[MouthBuiltShapeBoxControl] dimensions_m=3x2 planar_patch_m2=" f"{control['planar_area']:.4f} straight_run_m={control['straight']:.4f} " f"sharp_corners={control['corners']} detected={control_ok} " f"negative_control={'PASS' if control_ok else 'FAIL'}") candidates_pass = all(metrics["corner_gate"] for metrics in measured.values()) passed = control_ok and l36_failed and candidates_pass print(f"[MouthBuiltShapeSummary] scenes={len(measured)} candidates_pass=" f"{sum(metrics['corner_gate'] for metrics in measured.values())}/{len(measured)} " f"L36_control_fail={'PASS' if l36_failed else 'FAIL'} " f"box_control_fail={'PASS' if control_ok else 'FAIL'} " f"corner_gate={'PASS' if candidates_pass else 'FAIL'} " f"straight_or_planar_proxy_alerts={sum(metrics['proxy_alert'] for metrics in measured.values())} " f"planar_gate=informational_owner_accepted " f"status={'PASS' if passed else 'FAIL'}") raise SystemExit(0 if passed else 1) if __name__ == "__main__": main()