tools(audit): whole-surface mesh artifact audit with historical controls (L48, tool only); research R15
AuditWholeSurfaceL48.py scans every LOD0 triangle in the route corridor + 3 m and the chamber box + 3 m: welded-edge normal variation, high-angle edge density, max jumps and sliver area in 1 m cells, clustered and located, with thresholds from native patches in the same export, plus a detrended full-width floor profile. L45 ramp, L46 entrance and L47 wall-foot meshes fail it; plain native terrain passes. Red-triangle SVG maps included. Not wired into the tiers yet; the attempted Core fix is parked on wip/l48-surface-audit. Research R15: mesh artifact metrics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
No files matched your search
@@ -0,0 +1,709 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit every LOD0 mesh triangle in a cavern corridor for clustered defects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import array
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
CELL_M = 1.0
|
||||
CORRIDOR_MARGIN_M = 3.0
|
||||
OPEN_GROUND_EXTENSION_M = 5.0
|
||||
WELD_M = 0.0001
|
||||
|
||||
|
||||
class Triangle:
|
||||
__slots__ = ("verts", "center", "area", "normal", "smooth", "quality",
|
||||
"route_distance", "route_lateral", "route_s", "floor_z",
|
||||
"in_region", "reference")
|
||||
|
||||
def __init__(self, verts, area, normal, smooth, quality):
|
||||
self.verts = verts
|
||||
self.center = tuple(sum(v[i] for v in verts) / 3.0 for i in range(3))
|
||||
self.area = area
|
||||
self.normal = normal
|
||||
self.smooth = smooth
|
||||
self.quality = quality
|
||||
self.route_distance = 0.0
|
||||
self.route_lateral = 0.0
|
||||
self.route_s = 0.0
|
||||
self.floor_z = 0.0
|
||||
self.in_region = False
|
||||
self.reference = False
|
||||
|
||||
|
||||
def normalize(value):
|
||||
length = math.sqrt(sum(item * item for item in value))
|
||||
return tuple(item / length for item in value) if length > 1.0e-12 else (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def dot(a, b):
|
||||
return sum(a[i] * b[i] for i in range(3))
|
||||
|
||||
|
||||
def angle_degrees(a, b):
|
||||
return math.degrees(math.acos(max(-1.0, min(1.0, dot(a, b)))))
|
||||
|
||||
|
||||
def quantile(values, fraction):
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, max(0, int(math.ceil(fraction * len(ordered))) - 1))]
|
||||
|
||||
|
||||
def read_mesh(path: Path):
|
||||
triangles = []
|
||||
selected_tiles = 0
|
||||
lod_counts = {}
|
||||
with path.open("rb") as stream:
|
||||
prefix = stream.read(60)
|
||||
if len(prefix) != 60 or prefix[:8] != b"VFMSH001":
|
||||
raise ValueError(f"{path}: invalid VFMSH001 mesh header")
|
||||
version, voxel_um, tile_count = struct.unpack_from("<3I", prefix, 8)
|
||||
if version != 1 or not voxel_um or tile_count > 1_000_000:
|
||||
raise ValueError(f"{path}: unsupported mesh version or tile count")
|
||||
for _ in range(tile_count):
|
||||
header = stream.read(36)
|
||||
if len(header) != 36:
|
||||
raise ValueError(f"{path}: truncated tile header")
|
||||
key = struct.unpack_from("<4i", header, 0)
|
||||
surface_count, _surface_aux, vertex_count, index_count, mask_count = \
|
||||
struct.unpack_from("<5I", header, 16)
|
||||
lod_counts[key[3]] = lod_counts.get(key[3], 0) + 1
|
||||
position_bytes = stream.read(vertex_count * 12)
|
||||
normal_bytes = stream.read(vertex_count * 12)
|
||||
index_bytes = stream.read(index_count * 4)
|
||||
masks = stream.read(mask_count)
|
||||
if (len(position_bytes) != vertex_count * 12
|
||||
or len(normal_bytes) != vertex_count * 12
|
||||
or len(index_bytes) != index_count * 4
|
||||
or len(masks) != mask_count):
|
||||
raise ValueError(f"{path}: truncated tile payload")
|
||||
if key[3] != 0:
|
||||
continue
|
||||
selected_tiles += 1
|
||||
positions = array.array("f")
|
||||
positions.frombytes(position_bytes)
|
||||
normals = array.array("f")
|
||||
normals.frombytes(normal_bytes)
|
||||
indices = array.array("I")
|
||||
indices.frombytes(index_bytes)
|
||||
if sys.byteorder != "little":
|
||||
positions.byteswap()
|
||||
normals.byteswap()
|
||||
indices.byteswap()
|
||||
for offset in range(0, index_count - 2, 3):
|
||||
if masks and masks[offset // 3]:
|
||||
continue
|
||||
ids = indices[offset:offset + 3]
|
||||
if max(ids) >= surface_count:
|
||||
continue
|
||||
verts = tuple(tuple(float(positions[index * 3 + axis]) for axis in range(3))
|
||||
for index in ids)
|
||||
edge_a = tuple(verts[1][axis] - verts[0][axis] for axis in range(3))
|
||||
edge_b = tuple(verts[2][axis] - verts[0][axis] for axis in range(3))
|
||||
cross = (edge_a[1] * edge_b[2] - edge_a[2] * edge_b[1],
|
||||
edge_a[2] * edge_b[0] - edge_a[0] * edge_b[2],
|
||||
edge_a[0] * edge_b[1] - edge_a[1] * edge_b[0])
|
||||
cross_length = math.sqrt(dot(cross, cross))
|
||||
if cross_length <= 1.0e-12:
|
||||
continue
|
||||
face_normal = tuple(value / cross_length for value in cross)
|
||||
area = cross_length * 0.5
|
||||
smooth_normal = normalize(tuple(
|
||||
sum(float(normals[index * 3 + axis]) for index in ids) for axis in range(3)))
|
||||
if dot(face_normal, smooth_normal) < 0.0:
|
||||
face_normal = tuple(-value for value in face_normal)
|
||||
edge_squares = 0.0
|
||||
for a, b in ((verts[0], verts[1]), (verts[1], verts[2]),
|
||||
(verts[2], verts[0])):
|
||||
edge_squares += sum((a[i] - b[i]) ** 2 for i in range(3))
|
||||
quality = 4.0 * math.sqrt(3.0) * area / max(edge_squares, 1.0e-15)
|
||||
triangles.append(Triangle(verts, area, face_normal, smooth_normal, quality))
|
||||
if stream.read(1):
|
||||
raise ValueError(f"{path}: trailing bytes after mesh tiles")
|
||||
if not triangles:
|
||||
raise ValueError(f"{path}: no LOD0 surface triangles")
|
||||
return triangles, voxel_um, selected_tiles, lod_counts
|
||||
|
||||
|
||||
def bezier(points, t):
|
||||
one = 1.0 - t
|
||||
weights = (one ** 3, 3.0 * one * one * t, 3.0 * one * t * t, t ** 3)
|
||||
return tuple(sum(weights[i] * points[i][axis] for i in range(4)) for axis in range(3))
|
||||
|
||||
|
||||
def make_route(layout):
|
||||
edges = layout.get("cavern_edges", [])
|
||||
rooms = layout.get("cavern_rooms", [])
|
||||
if len(edges) != 1 or len(rooms) != 1:
|
||||
raise ValueError("cavern audit expects one resolved room and one edge in its layout")
|
||||
edge = edges[0]
|
||||
points = [[value / 1_000_000.0 for value in row]
|
||||
for row in edge["control_points_um"]]
|
||||
bore_radius = edge["bore_radius_um"] / 1_000_000.0
|
||||
bore_height = edge["bore_height_um"] / 1_000_000.0
|
||||
samples = [bezier(points, index / 160.0) for index in range(161)]
|
||||
horizontal_s = [0.0]
|
||||
for a, b in zip(samples, samples[1:]):
|
||||
horizontal_s.append(horizontal_s[-1] + math.hypot(b[0] - a[0], b[1] - a[1]))
|
||||
first_dx = samples[1][0] - samples[0][0]
|
||||
first_dy = samples[1][1] - samples[0][1]
|
||||
first_len = max(1.0e-9, math.hypot(first_dx, first_dy))
|
||||
extension = (samples[0][0] - first_dx / first_len * OPEN_GROUND_EXTENSION_M,
|
||||
samples[0][1] - first_dy / first_len * OPEN_GROUND_EXTENSION_M,
|
||||
samples[0][2] - (samples[1][2] - samples[0][2]) / first_len
|
||||
* OPEN_GROUND_EXTENSION_M)
|
||||
route_points = [extension, *samples]
|
||||
route_s = [-OPEN_GROUND_EXTENSION_M, *horizontal_s]
|
||||
segments = []
|
||||
for i, (a, b) in enumerate(zip(route_points, route_points[1:])):
|
||||
segments.append((a, b, route_s[i], route_s[i + 1]))
|
||||
room = rooms[0]
|
||||
room_center = tuple(value / 1_000_000.0 for value in room["center_um"])
|
||||
room_half = tuple(value / 1_000_000.0 for value in room["half_extents_um"])
|
||||
room_floor = room["floor_um"] / 1_000_000.0
|
||||
return {
|
||||
"segments": segments,
|
||||
"edge": edge,
|
||||
"bore_radius": bore_radius,
|
||||
"bore_height": bore_height,
|
||||
"room_center": room_center,
|
||||
"room_half": room_half,
|
||||
"room_floor": room_floor,
|
||||
"route_points": samples,
|
||||
"route_length": horizontal_s[-1],
|
||||
}
|
||||
|
||||
|
||||
def route_nearest(point, route):
|
||||
best = None
|
||||
for a, b, s0, s1 in route["segments"]:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
denominator = dx * dx + dy * dy
|
||||
t = 0.0 if denominator <= 1.0e-12 else max(0.0, min(1.0,
|
||||
((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / denominator))
|
||||
x, y = a[0] + dx * t, a[1] + dy * t
|
||||
distance = math.hypot(point[0] - x, point[1] - y)
|
||||
if best is None or distance < best[0]:
|
||||
segment_length = math.sqrt(max(denominator, 1.0e-12))
|
||||
lateral = (dx * (point[1] - y) - dy * (point[0] - x)) / segment_length
|
||||
best = (distance, s0 + (s1 - s0) * t,
|
||||
a[2] + (b[2] - a[2]) * t, lateral)
|
||||
return best
|
||||
|
||||
|
||||
def mark_region(triangles, route):
|
||||
room_c = route["room_center"]
|
||||
room_h = route["room_half"]
|
||||
radius = route["bore_radius"] + CORRIDOR_MARGIN_M
|
||||
for tri in triangles:
|
||||
x, y, z = tri.center
|
||||
distance, distance_s, floor_z, lateral = route_nearest(tri.center, route)
|
||||
tri.route_distance = distance
|
||||
tri.route_lateral = lateral
|
||||
tri.route_s = distance_s
|
||||
tri.floor_z = floor_z
|
||||
corridor = (-OPEN_GROUND_EXTENSION_M <= distance_s <= route["route_length"]
|
||||
and distance <= radius
|
||||
and floor_z - CORRIDOR_MARGIN_M <= z
|
||||
<= floor_z + route["bore_height"] + CORRIDOR_MARGIN_M)
|
||||
room_box = (abs(x - room_c[0]) <= room_h[0] + CORRIDOR_MARGIN_M
|
||||
and abs(y - room_c[1]) <= room_h[1] + CORRIDOR_MARGIN_M
|
||||
and route["room_floor"] - CORRIDOR_MARGIN_M <= z
|
||||
<= route["room_floor"] + 2.0 * room_h[2] + CORRIDOR_MARGIN_M)
|
||||
tri.in_region = corridor or room_box
|
||||
outside_bore = (distance > route["bore_radius"] + 0.45
|
||||
or abs(x - room_c[0]) > room_h[0] + 0.45
|
||||
or abs(y - room_c[1]) > room_h[1] + 0.45)
|
||||
# Keep the reference on native, ground-facing terrain. The outer mesh
|
||||
# also contains steep cavern walls and room edges, which are features.
|
||||
tri.reference = tri.in_region and outside_bore and tri.smooth[2] >= 0.45
|
||||
|
||||
|
||||
def vertex_key(point):
|
||||
return tuple(int(round(value / WELD_M)) for value in point)
|
||||
|
||||
|
||||
def make_edges(triangles, indices):
|
||||
edge_map = {}
|
||||
for index in indices:
|
||||
tri = triangles[index]
|
||||
points = tri.verts
|
||||
for a, b in ((0, 1), (1, 2), (2, 0)):
|
||||
ka, kb = vertex_key(points[a]), vertex_key(points[b])
|
||||
if ka == kb:
|
||||
continue
|
||||
key = (ka, kb) if ka < kb else (kb, ka)
|
||||
delta = tuple(points[a][axis] - points[b][axis] for axis in range(3))
|
||||
length = math.sqrt(dot(delta, delta))
|
||||
midpoint = tuple((points[a][axis] + points[b][axis]) * 0.5 for axis in range(3))
|
||||
edge_map.setdefault(key, []).append((index, length, midpoint))
|
||||
pairs = []
|
||||
for incident in edge_map.values():
|
||||
unique = {}
|
||||
for index, length, midpoint in incident:
|
||||
unique[index] = (length, midpoint)
|
||||
incident = list(unique.items())
|
||||
if len(incident) < 2:
|
||||
continue
|
||||
if len(incident) > 4:
|
||||
incident = incident[:4]
|
||||
for left in range(len(incident)):
|
||||
for right in range(left + 1, len(incident)):
|
||||
i, (length, midpoint) = incident[left]
|
||||
j, _ = incident[right]
|
||||
angle = max(angle_degrees(triangles[i].normal, triangles[j].normal),
|
||||
0.75 * angle_degrees(triangles[i].smooth, triangles[j].smooth))
|
||||
pairs.append((i, j, angle, length, midpoint))
|
||||
return pairs
|
||||
|
||||
|
||||
def cell_of(point):
|
||||
return tuple(math.floor(value / CELL_M) for value in point)
|
||||
|
||||
|
||||
def score_surface(triangles, considered, pairs, edge_cutoff, rough_cutoff,
|
||||
ref_cell_density):
|
||||
considered_set = set(considered)
|
||||
variation = {index: 0.0 for index in considered}
|
||||
max_jump = {index: 0.0 for index in considered}
|
||||
severe_count = {index: 0.0 for index in considered}
|
||||
for i, j, angle, length, _midpoint in pairs:
|
||||
if i not in considered_set or j not in considered_set:
|
||||
continue
|
||||
weighted = angle * length * 0.5
|
||||
variation[i] += weighted
|
||||
variation[j] += weighted
|
||||
max_jump[i] = max(max_jump[i], angle)
|
||||
max_jump[j] = max(max_jump[j], angle)
|
||||
if angle >= edge_cutoff:
|
||||
severe_count[i] += 0.5
|
||||
severe_count[j] += 0.5
|
||||
cells = {}
|
||||
for index in considered:
|
||||
tri = triangles[index]
|
||||
key = cell_of(tri.center)
|
||||
cell = cells.setdefault(key, {"area": 0.0, "variation": 0.0, "severe": 0.0,
|
||||
"sliver_area": 0.0, "slivers": 0, "triangles": 0, "max_jump": 0.0,
|
||||
"reference_area": 0.0, "reference_variation": 0.0,
|
||||
"reference_severe": 0.0, "reference_sliver_area": 0.0,
|
||||
"reference_slivers": 0})
|
||||
cell["area"] += tri.area
|
||||
cell["variation"] += variation[index]
|
||||
cell["severe"] += severe_count[index]
|
||||
cell["triangles"] += 1
|
||||
cell["max_jump"] = max(cell["max_jump"], max_jump[index])
|
||||
sliver = tri.quality < 0.07 and tri.area < 0.12
|
||||
if sliver:
|
||||
cell["sliver_area"] += tri.area
|
||||
cell["slivers"] += 1
|
||||
if tri.reference:
|
||||
cell["reference_area"] += tri.area
|
||||
cell["reference_variation"] += variation[index]
|
||||
cell["reference_severe"] += severe_count[index]
|
||||
if sliver:
|
||||
cell["reference_sliver_area"] += tri.area
|
||||
cell["reference_slivers"] += 1
|
||||
ref_rough = []
|
||||
ref_density = []
|
||||
ref_sliver_fraction = []
|
||||
for cell in cells.values():
|
||||
if cell["reference_area"] >= max(0.15, cell["area"] * 0.65):
|
||||
ref_rough.append(cell["reference_variation"] / max(cell["reference_area"], 1.0e-9))
|
||||
ref_density.append(cell["reference_severe"] / max(cell["reference_area"], 1.0e-9))
|
||||
ref_sliver_fraction.append(cell["reference_sliver_area"]
|
||||
/ max(cell["reference_area"], 1.0e-9))
|
||||
reference_median = quantile(ref_rough, 0.5)
|
||||
reference_mad = quantile([abs(value - reference_median) for value in ref_rough], 0.5)
|
||||
cell_rough_limit = max(10.0, quantile(ref_rough, 0.995) * 1.5,
|
||||
reference_median + 8.0 * reference_mad)
|
||||
edge_density_limit = max(1.5, quantile(ref_density, 0.995) * 1.7,
|
||||
ref_cell_density * 1.7)
|
||||
sliver_limit = max(0.22, quantile(ref_sliver_fraction, 0.995) + 0.18)
|
||||
hot = set()
|
||||
for key, cell in cells.items():
|
||||
area = max(cell["area"], 1.0e-9)
|
||||
roughness = cell["variation"] / area
|
||||
edge_density = cell["severe"] / area
|
||||
sliver_fraction = cell["sliver_area"] / area
|
||||
rough_cluster = roughness > cell_rough_limit and cell["severe"] >= 2.0
|
||||
edge_cluster = cell["severe"] >= 4.0 and edge_density > edge_density_limit
|
||||
sliver_cluster = cell["slivers"] >= 5 and sliver_fraction > sliver_limit
|
||||
if rough_cluster or edge_cluster or sliver_cluster:
|
||||
hot.add(key)
|
||||
cell["roughness_deg_per_m"] = roughness
|
||||
cell["severe_edge_density_m2"] = edge_density
|
||||
cell["sliver_area_fraction"] = sliver_fraction
|
||||
cell["reasons"] = [name for name, active in (("normal-variation", rough_cluster),
|
||||
("edge-density", edge_cluster), ("slivers", sliver_cluster)) if active]
|
||||
clusters = []
|
||||
flagged_cells = set()
|
||||
remaining = set(hot)
|
||||
while remaining:
|
||||
stack = [remaining.pop()]
|
||||
members = []
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
members.append(current)
|
||||
x, y, z = current
|
||||
neighbors = [(x + dx, y + dy, z + dz)
|
||||
for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)
|
||||
if dx or dy or dz]
|
||||
for neighbor in neighbors:
|
||||
if neighbor in remaining:
|
||||
remaining.remove(neighbor)
|
||||
stack.append(neighbor)
|
||||
cells_in_cluster = [cells[key] for key in members]
|
||||
cluster_area = sum(cell["area"] for cell in cells_in_cluster)
|
||||
cluster_severe = sum(cell["severe"] for cell in cells_in_cluster)
|
||||
cluster_slivers = sum(cell["slivers"] for cell in cells_in_cluster)
|
||||
accepted = len(members) >= 2 and cluster_area >= 0.25 and \
|
||||
(cluster_severe >= 8.0 or cluster_slivers >= 12)
|
||||
if accepted:
|
||||
flagged_cells.update(members)
|
||||
clusters.append({"cells": len(members), "area_m2": cluster_area,
|
||||
"severe_edges": cluster_severe, "sliver_triangles": cluster_slivers,
|
||||
"bounds_m": [[min(key[i] for key in members) * CELL_M,
|
||||
(max(key[i] for key in members) + 1) * CELL_M]
|
||||
for i in range(3)],
|
||||
"reasons": sorted({reason for cell in cells_in_cluster
|
||||
for reason in cell["reasons"]})})
|
||||
clusters.sort(key=lambda cluster: (cluster["severe_edges"], cluster["area_m2"]), reverse=True)
|
||||
flagged_triangles = [index for index in considered
|
||||
if cell_of(triangles[index].center) in flagged_cells]
|
||||
return {"cells": cells, "hot_cells": hot, "flagged_cells": flagged_cells,
|
||||
"clusters": clusters, "hot_triangles": flagged_triangles,
|
||||
"thresholds": {"edge_angle_deg": edge_cutoff,
|
||||
"roughness_deg_per_m": cell_rough_limit,
|
||||
"severe_edge_density_m2": edge_density_limit,
|
||||
"sliver_area_fraction": sliver_limit,
|
||||
"reference_cell_roughness_p995_deg_per_m": quantile(ref_rough, 0.995),
|
||||
"reference_cell_roughness_median_deg_per_m": reference_median}}
|
||||
|
||||
|
||||
def floor_profile_audit(triangles, considered, region_indices, route):
|
||||
"""Measure the whole floor patch as area-weighted bins, not selected rails."""
|
||||
if route is None:
|
||||
return {"present": False, "detected": False, "triangles": 0, "bins": 0,
|
||||
"lateral_bands": 0, "ripple_amplitude_m": 0.0,
|
||||
"max_adjacent_step_m": 0.0}
|
||||
along_step = 0.25
|
||||
lateral_step = 0.5
|
||||
bins = {}
|
||||
floor_triangles = 0
|
||||
wall_triangles = 0
|
||||
wall_bins = set()
|
||||
for index in region_indices:
|
||||
tri = triangles[index]
|
||||
height = tri.center[2] - tri.floor_z
|
||||
if (0.0 <= tri.route_s <= 10.0
|
||||
and route["bore_radius"] - 0.9 <= tri.route_distance
|
||||
<= route["bore_radius"] + 1.0
|
||||
and 0.45 <= height <= route["bore_height"] + 0.5
|
||||
and abs(tri.smooth[2]) <= 0.45):
|
||||
wall_triangles += 1
|
||||
wall_bins.add(int(tri.route_s))
|
||||
for index in considered:
|
||||
tri = triangles[index]
|
||||
if (tri.route_s < 0.0 or tri.route_s > min(10.0, route["route_length"])
|
||||
or tri.route_distance > min(1.9, route["bore_radius"] - 0.1)
|
||||
or abs(tri.center[2] - tri.floor_z) > 0.60
|
||||
or tri.smooth[2] < 0.55):
|
||||
continue
|
||||
floor_triangles += 1
|
||||
along_bin = int(math.floor(tri.route_s / along_step))
|
||||
lateral_bin = int(math.floor((tri.route_lateral + 2.0) / lateral_step))
|
||||
key = (lateral_bin, along_bin)
|
||||
row = bins.setdefault(key, [0.0, 0.0, 0])
|
||||
residual = tri.center[2] - tri.floor_z
|
||||
row[0] += residual * tri.area
|
||||
row[1] += tri.area
|
||||
row[2] += 1
|
||||
by_lateral = {}
|
||||
for (lateral_bin, along_bin), (weighted_residual, area, count) in bins.items():
|
||||
if area < 0.002:
|
||||
continue
|
||||
by_lateral.setdefault(lateral_bin, {})[along_bin] = weighted_residual / area
|
||||
amplitudes = []
|
||||
steps = []
|
||||
valid_bins = 0
|
||||
for values in by_lateral.values():
|
||||
ordered = sorted(values.items())
|
||||
run = []
|
||||
runs = []
|
||||
for item in ordered:
|
||||
if run and item[0] != run[-1][0] + 1:
|
||||
runs.append(run)
|
||||
run = []
|
||||
run.append(item)
|
||||
if run:
|
||||
runs.append(run)
|
||||
for segment in runs:
|
||||
if len(segment) < 8:
|
||||
continue
|
||||
valid_bins += len(segment)
|
||||
raw = [value for _, value in segment]
|
||||
highpass = []
|
||||
for position, value in enumerate(raw):
|
||||
nearby = raw[max(0, position - 4):min(len(raw), position + 5)]
|
||||
highpass.append(value - sum(nearby) / len(nearby))
|
||||
amplitudes.append(max(highpass) - min(highpass))
|
||||
steps.extend(abs(raw[i] - raw[i - 1]) for i in range(1, len(raw)))
|
||||
max_ripple = max(amplitudes, default=0.0) * 0.5
|
||||
max_step = max(steps, default=0.0)
|
||||
present = (floor_triangles >= 24 and valid_bins >= 8
|
||||
and wall_triangles >= 24 and len(wall_bins) >= 3)
|
||||
detected = present and (max_ripple > 0.015 or max_step > 0.050)
|
||||
return {"present": present, "detected": detected, "triangles": floor_triangles,
|
||||
"bins": valid_bins, "lateral_bands": len(by_lateral),
|
||||
"wall_triangles": wall_triangles, "wall_bins": len(wall_bins),
|
||||
"ripple_amplitude_m": max_ripple, "max_adjacent_step_m": max_step,
|
||||
"thresholds": {"ripple_amplitude_m": 0.015, "adjacent_step_m": 0.050}}
|
||||
|
||||
|
||||
def wall_band_measure(pairs, route, edge_cutoff):
|
||||
"""Locate high dihedrals around the bore-foot and the L47 0.25 m band edge."""
|
||||
if route is None:
|
||||
return {}
|
||||
bins = {}
|
||||
radius = route["bore_radius"]
|
||||
for _i, _j, angle, _length, midpoint in pairs:
|
||||
distance, route_s, floor_z, _lateral = route_nearest(midpoint, route)
|
||||
height = midpoint[2] - floor_z
|
||||
if (0.0 <= route_s <= 10.0 and abs(distance - radius) <= 0.75
|
||||
and -0.25 <= height <= 0.875):
|
||||
index = int(math.floor((height + 0.25) / 0.125))
|
||||
entry = bins.setdefault(index, [])
|
||||
entry.append(angle)
|
||||
result = {}
|
||||
for index, angles in sorted(bins.items()):
|
||||
center_height = (index + 0.5) * 0.125 - 0.25
|
||||
result[f"{center_height:.3f}"] = {
|
||||
"edges": len(angles),
|
||||
"over_reference_cutoff": sum(value >= edge_cutoff for value in angles),
|
||||
"p95_dihedral_deg": quantile(angles, 0.95),
|
||||
"max_dihedral_deg": max(angles, default=0.0),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def radial_wall_measure(pairs, route, edge_cutoff):
|
||||
"""Measure dihedrals across the hard bore-radius gate at the entrance foot."""
|
||||
if route is None:
|
||||
return {}
|
||||
bins = {}
|
||||
radius = route["bore_radius"]
|
||||
for _i, _j, angle, _length, midpoint in pairs:
|
||||
distance, route_s, floor_z, _lateral = route_nearest(midpoint, route)
|
||||
height = midpoint[2] - floor_z
|
||||
if (-5.0 <= route_s <= 3.0 and -1.0 <= height <= 0.75
|
||||
and abs(distance - radius) <= 1.0):
|
||||
index = int(math.floor((distance - (radius - 1.0)) / 0.125))
|
||||
bins.setdefault(index, []).append(angle)
|
||||
return {f"{radius - 1.0 + (index + 0.5) * 0.125:.3f}": {
|
||||
"edges": len(angles),
|
||||
"over_reference_cutoff": sum(value >= edge_cutoff for value in angles),
|
||||
"p95_dihedral_deg": quantile(angles, 0.95),
|
||||
"max_dihedral_deg": max(angles, default=0.0),
|
||||
} for index, angles in sorted(bins.items())}
|
||||
|
||||
|
||||
def svg_for(path, triangles, route, hot_triangles, label):
|
||||
selected = [triangles[index] for index in hot_triangles]
|
||||
all_region = [tri for tri in triangles if tri.in_region]
|
||||
points = [point for tri in all_region for point in tri.verts]
|
||||
if not points:
|
||||
return
|
||||
xmin, xmax = min(p[0] for p in points), max(p[0] for p in points)
|
||||
ymin, ymax = min(p[1] for p in points), max(p[1] for p in points)
|
||||
width, height, margin = 1200.0, 900.0, 35.0
|
||||
sx = (width - 2 * margin) / max(1.0e-6, xmax - xmin)
|
||||
sy = (height - 2 * margin) / max(1.0e-6, ymax - ymin)
|
||||
scale = min(sx, sy)
|
||||
draw_w, draw_h = (xmax - xmin) * scale, (ymax - ymin) * scale
|
||||
ox, oy = (width - draw_w) * 0.5, (height - draw_h) * 0.5
|
||||
def project(point):
|
||||
return ox + (point[0] - xmin) * scale, height - oy - (point[1] - ymin) * scale
|
||||
lines = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{int(width)}" height="{int(height)}" viewBox="0 0 {int(width)} {int(height)}">',
|
||||
'<rect width="100%" height="100%" fill="#fbfbfa"/>',
|
||||
f'<text x="28" y="28" font-family="sans-serif" font-size="19" fill="#222">{label}: whole-surface defect clusters (XY plan)</text>',
|
||||
'<g fill="#c8ced2" fill-opacity="0.24" stroke="#8b959b" stroke-opacity="0.12" stroke-width="0.3">']
|
||||
for tri in all_region:
|
||||
coords = [project(p) for p in tri.verts]
|
||||
lines.append('<polygon points="' + " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
|
||||
+ '"/>')
|
||||
lines.append('</g><g fill="#d7191c" fill-opacity="0.78" stroke="#7f0000" stroke-width="0.4">')
|
||||
for tri in selected:
|
||||
coords = [project(p) for p in tri.verts]
|
||||
lines.append('<polygon points="' + " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
|
||||
+ '"/>')
|
||||
route_coords = [project(p) for p in route["route_points"]]
|
||||
lines.append('</g><polyline points="' + " ".join(f"{x:.1f},{y:.1f}" for x, y in route_coords)
|
||||
+ '" fill="none" stroke="#1769aa" stroke-width="2"/>')
|
||||
lines.append(f'<text x="28" y="{int(height - 24)}" font-family="sans-serif" font-size="15" fill="#333">Blue: intended route · Red: flagged triangle cells · Gray: remaining ROI triangles · flagged triangles: {len(selected)}</text>')
|
||||
lines.append('</svg>')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def audit_case(label, mesh_path, layout_path, expect, focus, svg_dir):
|
||||
triangles, voxel_um, tile_count, lod_counts = read_mesh(mesh_path)
|
||||
route = None
|
||||
if layout_path != Path("-"):
|
||||
layout = json.loads(layout_path.read_text(encoding="utf-8"))
|
||||
route = make_route(layout)
|
||||
mark_region(triangles, route)
|
||||
region_indices = [i for i, tri in enumerate(triangles) if tri.in_region]
|
||||
else:
|
||||
region_indices = list(range(len(triangles)))
|
||||
for tri in triangles:
|
||||
tri.in_region = True
|
||||
tri.reference = True
|
||||
if len(region_indices) < 24:
|
||||
raise ValueError(f"{label}: only {len(region_indices)} triangles intersect the ROI")
|
||||
reference_indices = [i for i in region_indices if triangles[i].reference]
|
||||
if len(reference_indices) < 24:
|
||||
reference_indices = region_indices
|
||||
for index in region_indices:
|
||||
triangles[index].reference = True
|
||||
edge_pairs = make_edges(triangles, region_indices)
|
||||
reference_edges = [edge[2] for edge in edge_pairs
|
||||
if triangles[edge[0]].reference and triangles[edge[1]].reference]
|
||||
edge_cutoff = max(35.0, quantile(reference_edges, 0.995))
|
||||
reference_set = set(reference_indices)
|
||||
ref_roughness = {index: 0.0 for index in reference_indices}
|
||||
for i, j, angle, length, _midpoint in edge_pairs:
|
||||
if i in reference_set and j in reference_set:
|
||||
ref_roughness[i] += angle * length * 0.5
|
||||
ref_roughness[j] += angle * length * 0.5
|
||||
ref_values = [ref_roughness[index] / max(triangles[index].area, 1.0e-9)
|
||||
for index in reference_indices]
|
||||
rough_cutoff = 10.0
|
||||
ref_density = 0.0
|
||||
ref_area = sum(triangles[index].area for index in reference_indices)
|
||||
ref_severe = 0.0
|
||||
for i, j, angle, _length, _midpoint in edge_pairs:
|
||||
if i in reference_set and j in reference_set and angle >= edge_cutoff:
|
||||
ref_severe += 1.0
|
||||
ref_density = ref_severe / max(ref_area, 1.0e-9)
|
||||
if focus == "floor":
|
||||
considered = [i for i in region_indices if route is not None
|
||||
and triangles[i].route_s >= 1.5
|
||||
and triangles[i].route_s <= 10.0
|
||||
and triangles[i].route_distance <= 1.25
|
||||
and abs(triangles[i].center[2] - triangles[i].floor_z) <= 0.24
|
||||
and triangles[i].smooth[2] >= 0.55]
|
||||
else:
|
||||
considered = region_indices
|
||||
if len(considered) < 24:
|
||||
raise ValueError(f"{label}: focus={focus} has only {len(considered)} triangles")
|
||||
scores = score_surface(triangles, considered, edge_pairs, edge_cutoff,
|
||||
rough_cutoff, ref_density)
|
||||
floor_profile = floor_profile_audit(triangles, considered, region_indices, route)
|
||||
wall_band = wall_band_measure(edge_pairs, route, edge_cutoff)
|
||||
radial_wall = radial_wall_measure(edge_pairs, route, edge_cutoff)
|
||||
found = bool(scores["clusters"]) or floor_profile["detected"]
|
||||
passed = expect == "measure" or (expect == "fail" and found) or (expect == "pass" and not found)
|
||||
if svg_dir and route is not None:
|
||||
svg_for(svg_dir / f"flagged-regions-{label}.svg", triangles, route,
|
||||
scores["hot_triangles"], label)
|
||||
cluster_summary = scores["clusters"][:8]
|
||||
result = {
|
||||
"label": label,
|
||||
"mesh": str(mesh_path),
|
||||
"layout": None if layout_path == Path("-") else str(layout_path),
|
||||
"focus": focus,
|
||||
"expected": expect,
|
||||
"observed": "fail" if found else "pass",
|
||||
"status": "PASS" if passed else "FAIL",
|
||||
"voxel_um": voxel_um,
|
||||
"lod0_tiles": tile_count,
|
||||
"lod_tile_counts": lod_counts,
|
||||
"triangles_loaded_lod0": len(triangles),
|
||||
"triangles_in_roi": len(region_indices),
|
||||
"triangles_audited": len(considered),
|
||||
"adjacent_pairs": len(edge_pairs),
|
||||
"reference_triangles": len(reference_indices),
|
||||
"reference_edge_p995_deg": quantile(reference_edges, 0.995),
|
||||
"reference_p995_normal_variation_deg_per_m": quantile(ref_values, 0.995),
|
||||
"thresholds": scores["thresholds"],
|
||||
"flagged_cells": len(scores["hot_cells"]),
|
||||
"flagged_triangles": len(scores["hot_triangles"]),
|
||||
"floor_profile": floor_profile,
|
||||
"wall_band_height_histogram": wall_band,
|
||||
"radial_wall_histogram": radial_wall,
|
||||
"clusters": cluster_summary,
|
||||
}
|
||||
print(f"[WholeSurfaceAudit] label={label} focus={focus} lod0_tiles={tile_count} "
|
||||
f"triangles_roi={len(region_indices)} audited={len(considered)} "
|
||||
f"adjacent_pairs={len(edge_pairs)} reference={len(reference_indices)} "
|
||||
f"edge_cutoff_deg={edge_cutoff:.2f} "
|
||||
f"roughness_cutoff_deg_m={scores['thresholds']['roughness_deg_per_m']:.2f} "
|
||||
f"hot_cells={len(scores['hot_cells'])} flagged_triangles={len(scores['hot_triangles'])} "
|
||||
f"clusters={len(scores['clusters'])} observed={result['observed'].upper()} "
|
||||
f"expected={expect.upper()} status={result['status']}")
|
||||
print(f"[WholeSurfaceFloorProfile] label={label} triangles={floor_profile['triangles']} "
|
||||
f"bins={floor_profile['bins']} lateral_bands={floor_profile['lateral_bands']} "
|
||||
f"wall_triangles={floor_profile.get('wall_triangles', 0)} "
|
||||
f"wall_bins={floor_profile.get('wall_bins', 0)} "
|
||||
f"ripple_amplitude_m={floor_profile['ripple_amplitude_m']:.4f} "
|
||||
f"max_adjacent_step_m={floor_profile['max_adjacent_step_m']:.4f} "
|
||||
f"observed={'FAIL' if floor_profile['detected'] else ('PASS' if floor_profile['present'] else 'NOT_PRESENT')}")
|
||||
if wall_band:
|
||||
print(f"[WholeSurfaceWallBandMeasure] label={label} voxel_m={voxel_um / 1_000_000.0:.3f} "
|
||||
f"recarve_edge_height_m=0.250 radial_band_m={route['bore_radius']:.3f} "
|
||||
f"height_histogram={json.dumps(wall_band, sort_keys=True, separators=(',', ':'))}")
|
||||
print(f"[WholeSurfaceRadialWallMeasure] label={label} route_s_m=-5..3 "
|
||||
f"height_above_floor_m=-1..0.75 bore_radius_m={route['bore_radius']:.3f} "
|
||||
f"radial_histogram={json.dumps(radial_wall, sort_keys=True, separators=(',', ':'))}")
|
||||
for index, cluster in enumerate(cluster_summary):
|
||||
print(f"[WholeSurfaceCluster] label={label} index={index} cells={cluster['cells']} "
|
||||
f"area_m2={cluster['area_m2']:.3f} severe_edges={cluster['severe_edges']:.1f} "
|
||||
f"slivers={cluster['sliver_triangles']} bounds_m={cluster['bounds_m']} "
|
||||
f"reasons={','.join(cluster['reasons'])}")
|
||||
hot_cells = scores["cells"]
|
||||
for key in sorted(scores["hot_cells"], key=lambda item: (
|
||||
hot_cells[item]["severe"], hot_cells[item]["variation"]), reverse=True)[:12]:
|
||||
cell = hot_cells[key]
|
||||
local = route_nearest(tuple((axis + 0.5) * CELL_M for axis in key), route) if route else None
|
||||
print(f"[WholeSurfaceHotCell] label={label} cell_m=({key[0]},{key[1]},{key[2]}) "
|
||||
f"route_s_m={local[1] if local else 0.0:.2f} radial_m={local[0] if local else 0.0:.2f} "
|
||||
f"height_above_floor_m={(key[2] + 0.5 - local[2]) if local else 0.0:.2f} "
|
||||
f"area_m2={cell['area']:.3f} roughness_deg_m={cell['roughness_deg_per_m']:.2f} "
|
||||
f"severe_edges={cell['severe']:.1f} edge_density_m2={cell['severe_edge_density_m2']:.2f} "
|
||||
f"slivers={cell['slivers']} max_jump_deg={cell['max_jump']:.2f} "
|
||||
f"reasons={','.join(cell['reasons'])}")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--case", nargs=5, action="append", required=True,
|
||||
metavar=("LABEL", "MESH", "LAYOUT_OR_DASH", "EXPECT", "FOCUS"))
|
||||
parser.add_argument("--json", type=Path, required=True)
|
||||
parser.add_argument("--svg-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
results = []
|
||||
for label, mesh, layout, expect, focus in args.case:
|
||||
if expect not in ("pass", "fail", "measure") or focus not in ("whole", "floor"):
|
||||
parser.error("EXPECT must be pass/fail/measure and FOCUS must be whole/floor")
|
||||
results.append(audit_case(label, Path(mesh), Path(layout), expect, focus, args.svg_dir))
|
||||
args.json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.json.write_text(json.dumps({"audit": "whole-surface-l48-v1", "cases": results},
|
||||
indent=2) + "\n", encoding="utf-8")
|
||||
failures = [result for result in results if result["status"] != "PASS"]
|
||||
print(f"[WholeSurfaceAuditSummary] cases={len(results)} failed={len(failures)} "
|
||||
f"json={args.json} status={'FAIL' if failures else 'PASS'}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture L48 cavern entrance views and audit the exported floor profile."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
VERIFY = ROOT / "Standalone/Tools/verify"
|
||||
LINUX37 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux37"
|
||||
sys.path.insert(0, str(VERIFY))
|
||||
from runner import bounded_run, current_stamp, first_stamp
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot import {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
PROFILE = load_module("l45_ring_profile", ROOT /
|
||||
"Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py")
|
||||
SHAPE = load_module("l45_shape_audit", LINUX37 / "AuditMouthShapeL37.py")
|
||||
|
||||
PARSER = argparse.ArgumentParser(description=__doc__)
|
||||
PARSER.add_argument("--layout", type=Path, required=True)
|
||||
PARSER.add_argument("--artifact-dir", type=Path, required=True)
|
||||
PARSER.add_argument("--capture-dir", type=Path, required=True)
|
||||
PARSER.add_argument("--data", type=Path, default=ROOT / "Standalone/data/owner_world.json")
|
||||
OPTIONS = PARSER.parse_args()
|
||||
|
||||
ARTIFACT_DIR = OPTIONS.artifact_dir.resolve()
|
||||
CAPTURE_DIR = OPTIONS.capture_dir.resolve()
|
||||
LAYOUT_PATH = OPTIONS.layout.resolve()
|
||||
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PNG_DIR = ARTIFACT_DIR / "capture-png"
|
||||
PNG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
HEADLESS = Path(os.environ["VOXELFORGE_L37_HEADLESS"])
|
||||
RENDERER = Path(os.environ["VOXELFORGE_L37_RENDERER"])
|
||||
EXPECTED_STAMP = current_stamp()
|
||||
|
||||
|
||||
def run_logged(name: str, command: list[str], *, capture: bool = False) -> str:
|
||||
log = ARTIFACT_DIR / f"{name}.log"
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = bounded_run(command, timeout=1800, stdout=stream)
|
||||
elapsed = time.monotonic() - started
|
||||
output = log.read_text(encoding="utf-8", errors="replace")
|
||||
if result is None:
|
||||
raise RuntimeError(f"{name} timed out; see {log}")
|
||||
print(f"[L48CaptureRun] step={name} elapsed={elapsed:.1f}s "
|
||||
f"exit={result.returncode} log={log}", flush=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"{name} failed; see {log}")
|
||||
if capture and (first_stamp(output) != EXPECTED_STAMP
|
||||
or first_stamp(bounded_run(
|
||||
[str(command[0]), "--print-source-stamp"], timeout=15).stdout or "")
|
||||
!= EXPECTED_STAMP):
|
||||
raise RuntimeError(f"{name} executable source stamp mismatch")
|
||||
return output
|
||||
|
||||
|
||||
def stamp_by_id(layout: dict, stable_id: str) -> dict:
|
||||
for stamp in layout.get("stamps", []):
|
||||
if stamp.get("stable_id") == stable_id:
|
||||
return stamp
|
||||
raise RuntimeError(f"missing stamp {stable_id}")
|
||||
|
||||
|
||||
def metres(stamp: dict, key: str = "position_um") -> tuple[float, float, float]:
|
||||
return tuple(value / 1_000_000.0 for value in stamp[key])
|
||||
|
||||
|
||||
def camera_angles(camera, target):
|
||||
dx, dy, dz = (target[index] - camera[index] for index in range(3))
|
||||
return (math.degrees(math.atan2(dx, dy)),
|
||||
math.degrees(math.atan2(dz, math.hypot(dx, dy))))
|
||||
|
||||
|
||||
def fmt(values) -> str:
|
||||
return ",".join(f"{value:.6f}" for value in values)
|
||||
|
||||
|
||||
def camera_spec(camera, target):
|
||||
yaw, pitch = camera_angles(camera, target)
|
||||
return (*camera, yaw, pitch)
|
||||
|
||||
|
||||
def edge_point(edge: dict, t: float) -> tuple[float, float, float]:
|
||||
points = edge["control_points_um"]
|
||||
one = 1.0 - t
|
||||
weights = (one ** 3, 3 * one ** 2 * t, 3 * one * t ** 2, t ** 3)
|
||||
return tuple(sum(weights[index] * points[index][axis]
|
||||
for index in range(4)) / 1_000_000.0
|
||||
for axis in range(3))
|
||||
|
||||
|
||||
def cavern_features(layout: dict):
|
||||
if (layout.get("cavern_graph_vocabulary_version") != 1
|
||||
or len(layout.get("cavern_rooms", [])) != 1
|
||||
or len(layout.get("cavern_edges", [])) != 1):
|
||||
raise RuntimeError("L48 capture needs one resolved cavern room and edge")
|
||||
room = layout["cavern_rooms"][0]
|
||||
edge = layout["cavern_edges"][0]
|
||||
return room, edge
|
||||
|
||||
|
||||
layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8"))
|
||||
room, edge = cavern_features(layout)
|
||||
portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1")
|
||||
floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor")
|
||||
dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais")
|
||||
entrance = edge_point(edge, 0.0)
|
||||
next_ramp = edge_point(edge, 0.08)
|
||||
portal = metres(portal_stamp)
|
||||
chamber = metres(room, "center_um")
|
||||
floor = metres(floor_stamp)
|
||||
floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0
|
||||
dais = metres(dais_stamp)
|
||||
dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0
|
||||
ground_z = entrance[2]
|
||||
direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1])
|
||||
direction = ((next_ramp[0] - entrance[0]) / direction_length,
|
||||
(next_ramp[1] - entrance[1]) / direction_length)
|
||||
outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1])
|
||||
outward = ((entrance[0] - chamber[0]) / outward_length,
|
||||
(entrance[1] - chamber[1]) / outward_length)
|
||||
mouth_normal = (-direction[1], direction[0])
|
||||
|
||||
mesh = ARTIFACT_DIR / "landmark-l48.vfmesh"
|
||||
native_mesh = ARTIFACT_DIR / "control-plain-native.vfmesh"
|
||||
export_anchor = edge_point(edge, 0.25)
|
||||
export_camera = (export_anchor[0], export_anchor[1], export_anchor[2] + 1.4)
|
||||
export_target_floor = edge_point(edge, 0.42)
|
||||
export_target = (export_target_floor[0], export_target_floor[1],
|
||||
export_target_floor[2] + 1.2)
|
||||
run_logged("landmark-export", [str(HEADLESS), str(OPTIONS.data), "--workers=12",
|
||||
"--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline",
|
||||
"--force-generate", "--clip-radius=4", "--max-clip-level=0", "--max-level=0",
|
||||
"--render-distance-chunks=0", "--recipe=surface-world", "--landmark-chamber-demo",
|
||||
f"--camera={fmt(camera_spec(export_camera, export_target))}",
|
||||
f"--mesh-export={mesh}", f"--resolved-layout-output={LAYOUT_PATH}"], capture=True)
|
||||
run_logged("plain-native-export", [str(HEADLESS), str(OPTIONS.data), "--workers=12",
|
||||
"--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline",
|
||||
"--force-generate", "--clip-radius=4", "--max-clip-level=0", "--max-level=0",
|
||||
"--render-distance-chunks=0", "--recipe=surface-world",
|
||||
f"--camera={fmt(camera_spec(export_camera, export_target))}",
|
||||
f"--mesh-export={native_mesh}",
|
||||
f"--resolved-layout-output={ARTIFACT_DIR / 'control-plain-native-layout.json'}"],
|
||||
capture=True)
|
||||
layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8"))
|
||||
room, edge = cavern_features(layout)
|
||||
portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1")
|
||||
floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor")
|
||||
dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais")
|
||||
entrance = edge_point(edge, 0.0)
|
||||
next_ramp = edge_point(edge, 0.08)
|
||||
portal = metres(portal_stamp)
|
||||
chamber = metres(room, "center_um")
|
||||
floor = metres(floor_stamp)
|
||||
floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0
|
||||
dais = metres(dais_stamp)
|
||||
dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0
|
||||
ground_z = entrance[2]
|
||||
direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1])
|
||||
direction = ((next_ramp[0] - entrance[0]) / direction_length,
|
||||
(next_ramp[1] - entrance[1]) / direction_length)
|
||||
outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1])
|
||||
outward = ((entrance[0] - chamber[0]) / outward_length,
|
||||
(entrance[1] - chamber[1]) / outward_length)
|
||||
if not mesh.is_file() or mesh.stat().st_size == 0:
|
||||
raise RuntimeError(f"missing mesh export {mesh}")
|
||||
|
||||
entrance_audit = ROOT / "Standalone/Research/WorldAssemblerM3/Linux47/MeasureEntranceL47.py"
|
||||
run_logged("entrance-floor-control-l46", [sys.executable, str(entrance_audit),
|
||||
"--mesh", str(ROOT / "Saved/Linux/l46/landmark-l46.vfmesh"),
|
||||
"--layout", str(ROOT / "Saved/Linux/l46/layouts/landmark-reachability-clang.json"),
|
||||
"--out", str(ARTIFACT_DIR / "entrance-profile-control-l46.json"), "--expect=fail"])
|
||||
run_logged("entrance-floor-audit", [sys.executable, str(entrance_audit),
|
||||
"--mesh", str(mesh), "--layout", str(LAYOUT_PATH),
|
||||
"--out", str(ARTIFACT_DIR / "entrance-profile-l48.json"), "--expect=pass"])
|
||||
|
||||
voxel_um, triangles, tile_count = PROFILE.read_scene(str(mesh), 0,
|
||||
floor_top - 8.0, entrance[2] + 9.0,
|
||||
(min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0,
|
||||
min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0))
|
||||
bounds = (min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0,
|
||||
min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0)
|
||||
buckets = PROFILE.build_buckets(triangles, True, bounds, cell_size=1.0)
|
||||
ground_candidates = []
|
||||
for ix in range(-20, 21):
|
||||
for iy in range(-20, 21):
|
||||
x, y = portal[0] + ix * 0.5, portal[1] + iy * 0.5
|
||||
dx, dy = x - portal[0], y - portal[1]
|
||||
distance = math.hypot(dx, dy)
|
||||
if not 6.0 <= distance <= 10.0:
|
||||
continue
|
||||
hit = PROFILE.sample_surface(buckets, x, y, True, 1.0,
|
||||
preferred_height=ground_z)
|
||||
if hit is None or abs(hit[0] - ground_z) > 3.0 or hit[1][2] < 0.85:
|
||||
continue
|
||||
alignment = max(-1.0, min(1.0,
|
||||
(dx * mouth_normal[0] + dy * mouth_normal[1]) / distance))
|
||||
angle = math.acos(alignment)
|
||||
score = abs(distance - 7.0) + angle * 4.0
|
||||
ground_candidates.append((score, x, y, hit[0]))
|
||||
if not ground_candidates:
|
||||
raise RuntimeError("no walkable mesh ground lies 6 to 10 m from the cave mouth")
|
||||
_, ground_x, ground_y, outside_ground = min(ground_candidates)
|
||||
inside_floor = PROFILE.sample_surface(buckets, chamber[0], chamber[1], True, 1.0,
|
||||
preferred_height=floor_top)
|
||||
if inside_floor is None or abs(inside_floor[0] - floor_top) > 1.5:
|
||||
raise RuntimeError(f"chamber floor mesh hit {inside_floor} does not match designed floor {floor_top:.3f} m")
|
||||
floor_top = inside_floor[0]
|
||||
|
||||
ground_camera = (ground_x, ground_y, outside_ground + 1.6)
|
||||
entry_ground = PROFILE.sample_surface(buckets, entrance[0], entrance[1], True, 1.0,
|
||||
preferred_height=ground_z)
|
||||
if entry_ground is None:
|
||||
raise RuntimeError("cave mouth has no walkable ground surface in the exported mesh")
|
||||
entry_camera = (entrance[0] + mouth_normal[0] * 3.8,
|
||||
entrance[1] + mouth_normal[1] * 3.8, ground_z + 1.6)
|
||||
entry_target_floor = edge_point(edge, 0.30)
|
||||
entry_target = (entry_target_floor[0], entry_target_floor[1], entry_target_floor[2] + 1.4)
|
||||
low_camera = (entrance[0] + outward[0] * 1.0,
|
||||
entrance[1] + outward[1] * 1.0, ground_z + 0.65)
|
||||
low_target_floor = edge_point(edge, 0.24)
|
||||
low_target = (low_target_floor[0], low_target_floor[1], low_target_floor[2] + 0.15)
|
||||
to_dais = (dais[0] - chamber[0], dais[1] - chamber[1])
|
||||
to_dais_length = math.hypot(*to_dais)
|
||||
opposite_dais = (-to_dais[0] / to_dais_length, -to_dais[1] / to_dais_length)
|
||||
inside_x = chamber[0]
|
||||
inside_y = chamber[1]
|
||||
inside_floor = PROFILE.sample_surface(buckets, inside_x, inside_y, True, 1.0,
|
||||
preferred_height=floor_top)
|
||||
inside_eye_z = inside_floor[0] + 1.6
|
||||
chamber_camera = (chamber[0] + opposite_dais[0] * 5.5,
|
||||
chamber[1] + opposite_dais[1] * 5.5, inside_eye_z)
|
||||
chamber_target = (dais[0], dais[1], dais_top - 0.25)
|
||||
overview_camera = (entrance[0] + outward[0] * 18.0,
|
||||
entrance[1] + outward[1] * 18.0, outside_ground + 15.0)
|
||||
overview_target = ((entrance[0] + chamber[0]) * 0.5,
|
||||
(entrance[1] + chamber[1]) * 0.5,
|
||||
(ground_z + floor_top) * 0.5)
|
||||
inward = (-outward[0], -outward[1])
|
||||
clip_point = (entrance[0] + outward[0] * 0.5,
|
||||
entrance[1] + outward[1] * 0.5)
|
||||
overview_clip = (inward[0], inward[1], 0.0,
|
||||
-(inward[0] * clip_point[0] + inward[1] * clip_point[1]))
|
||||
|
||||
views = (
|
||||
("ground-entrance", ground_camera, (portal[0], portal[1], portal[2]),
|
||||
(portal[0], portal[1], ground_z, 20.0), -509.0),
|
||||
("entrance-looking-in", entry_camera, entry_target, (entrance[0], entrance[1], entrance[2], 16.0), -507.0),
|
||||
("entrance-floor-low", low_camera, low_target,
|
||||
(entrance[0], entrance[1], entrance[2], 12.0), ground_z),
|
||||
("inside-to-dais", chamber_camera, chamber_target,
|
||||
(chamber[0], chamber[1], chamber[2], 15.0), chamber[2]),
|
||||
("overview-entrance-and-chamber", overview_camera, overview_target,
|
||||
((entrance[0] + chamber[0]) * 0.5,
|
||||
(entrance[1] + chamber[1]) * 0.5,
|
||||
(ground_z + floor_top) * 0.5, 34.0), -509.0),
|
||||
)
|
||||
|
||||
for name, camera, target, focus, height_center in views:
|
||||
png = PNG_DIR / f"{name}.png"
|
||||
jpg = CAPTURE_DIR / f"{name}.jpg"
|
||||
yaw, pitch = camera_angles(camera, target)
|
||||
fov = (80.0 if name == "overview-entrance-and-chamber" else
|
||||
70.0 if name in ("inside-to-dais", "entrance-looking-in") else
|
||||
58.0 if name == "entrance-floor-low" else 65.0
|
||||
if name == "ground-entrance" else 60.0)
|
||||
if name == "ground-entrance":
|
||||
fov = 55.0
|
||||
print(f"[L48CaptureCamera] view={name} camera_m=({fmt(camera)}) "
|
||||
f"target_m=({fmt(target)}) yaw_deg={yaw:.3f} pitch_deg={pitch:.3f} "
|
||||
f"fov_deg={fov:.1f} entrance_distance_m={math.dist(camera, portal):.3f}", flush=True)
|
||||
render = [str(RENDERER), str(mesh), str(png), "--size=1600x900", f"--fov-deg={fov:.1f}",
|
||||
f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}", f"--pitch-deg={pitch:.6f}",
|
||||
"--shade=readable", "--headlamp=on", "--height-tint=on",
|
||||
"--light-direction=0.3,-0.4,0.85",
|
||||
f"--height-center-m={(floor_top if name == 'inside-to-dais' else height_center):.6f}",
|
||||
f"--height-range-m={4 if name == 'inside-to-dais' else 6 if name == 'entrance-floor-low' else 16}",
|
||||
"--tile-skirts=off", "--tile-edges=off",
|
||||
f"--focus-region={fmt(focus)}"]
|
||||
if name == "overview-entrance-and-chamber":
|
||||
render.append(f"--clip-plane={fmt(overview_clip)}")
|
||||
run_logged(f"{name}-render", render, capture=True)
|
||||
for quality in (4, 6, 8):
|
||||
run_logged(f"{name}-jpeg-q{quality}", ["ffmpeg", "-nostdin", "-hide_banner",
|
||||
"-loglevel", "error", "-y", "-i", str(png), "-frames:v", "1",
|
||||
"-q:v", str(quality), str(jpg)])
|
||||
if jpg.is_file() and 0 < jpg.stat().st_size <= 300_000:
|
||||
break
|
||||
if not jpg.is_file() or not 0 < jpg.stat().st_size <= 300_000:
|
||||
raise RuntimeError(f"{jpg.name} exceeds the 300 KB capture limit")
|
||||
quality_log = run_logged(f"{name}-quality", [sys.executable,
|
||||
str(VERIFY / "capture_quality.py"), str(jpg)])
|
||||
print(f"[L48CaptureJPEG] file={jpg} bytes={jpg.stat().st_size} "
|
||||
f"quality_log={quality_log.strip().replace(chr(10), ' ')} status=PASS", flush=True)
|
||||
|
||||
|
||||
def region_triangles(center, radius_xy, z_min, z_max):
|
||||
selected = []
|
||||
for triangle in triangles:
|
||||
points = tuple(vertex[0] for vertex in triangle[2])
|
||||
centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3))
|
||||
if (math.hypot(centroid[0] - center[0], centroid[1] - center[1]) <= radius_xy
|
||||
and z_min <= centroid[2] <= z_max):
|
||||
selected.append(triangle)
|
||||
return selected
|
||||
|
||||
|
||||
def tunnel_wall_triangles(edge, t, height_above_floor):
|
||||
center = edge_point(edge, t)
|
||||
before = edge_point(edge, max(0.0, t - 0.01))
|
||||
after = edge_point(edge, min(1.0, t + 0.01))
|
||||
tx, ty = after[0] - before[0], after[1] - before[1]
|
||||
tangent_length = math.hypot(tx, ty)
|
||||
tx, ty = tx / tangent_length, ty / tangent_length
|
||||
expected_radius = edge["bore_radius_um"] / 1_000_000.0 * math.sqrt(
|
||||
1.0 - (height_above_floor / (edge["bore_height_um"] / 1_000_000.0)) ** 2)
|
||||
selected = []
|
||||
for triangle in triangles:
|
||||
points = tuple(vertex[0] for vertex in triangle[2])
|
||||
centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3))
|
||||
dx, dy = centroid[0] - center[0], centroid[1] - center[1]
|
||||
longitudinal = dx * tx + dy * ty
|
||||
lateral = abs(-dy * tx + dx * ty)
|
||||
vertical = centroid[2] - center[2]
|
||||
if (abs(longitudinal) <= 1.5
|
||||
and abs(lateral - expected_radius) <= 0.7
|
||||
and abs(vertical - height_above_floor) <= 0.75):
|
||||
selected.append(triangle)
|
||||
print(f"[L47TunnelWallWindow] t={t:.3f} center_m=({center[0]:.3f},"
|
||||
f"{center[1]:.3f},{center[2]:.3f}) expected_radius_m={expected_radius:.3f} "
|
||||
f"longitudinal_half_width_m=1.500 vertical_half_width_m=0.750 "
|
||||
f"triangles={len(selected)}", flush=True)
|
||||
return selected
|
||||
|
||||
|
||||
def audit_shape(name, region, center):
|
||||
metrics = SHAPE.analyze_triangles(region, center[:2], center[2],
|
||||
crop_to_center=False, include_sides=True)
|
||||
passed = (metrics["triangles"] > 0
|
||||
and metrics["planar_area"] < SHAPE.PLANE_LIMIT_M2
|
||||
and metrics["straight"] <= SHAPE.STRAIGHT_LIMIT_M
|
||||
and metrics["corners"] == 0)
|
||||
print(f"[LandmarkBuiltShape] region={name} triangles={metrics['triangles']} "
|
||||
f"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"thresholds=({SHAPE.PLANE_LIMIT_M2:.2f}m2,{SHAPE.STRAIGHT_LIMIT_M:.2f}m,"
|
||||
f"{SHAPE.CORNER_TURN_LIMIT_DEG:.0f}deg) status={'PASS' if passed else 'FAIL'}",
|
||||
flush=True)
|
||||
if not passed:
|
||||
raise RuntimeError(f"L37 built-shape thresholds failed in {name}")
|
||||
return metrics
|
||||
|
||||
|
||||
entrance_metrics = audit_shape("cave-mouth", region_triangles(
|
||||
portal, 5.0, ground_z + 0.8, ground_z + 4.5),
|
||||
(portal[0], portal[1], ground_z))
|
||||
tunnel_mid = edge_point(edge, 0.64)
|
||||
tunnel_metrics = audit_shape("tunnel-wall-edge", tunnel_wall_triangles(
|
||||
edge, 0.64, 1.2),
|
||||
(tunnel_mid[0], tunnel_mid[1], tunnel_mid[2] + 1.2))
|
||||
dais_surface = PROFILE.sample_surface(buckets, dais[0], dais[1], True, 1.0,
|
||||
preferred_height=dais_top - 0.4)
|
||||
if dais_surface is None:
|
||||
raise RuntimeError("dais stamp has no upward-facing surface in the exported mesh")
|
||||
dais_rise = dais_surface[0] - floor_top
|
||||
print(f"[LandmarkDaisSurface] center_m=({dais[0]:.3f},{dais[1]:.3f}) "
|
||||
f"floor_z_m={floor_top:.3f} top_z_m={dais_surface[0]:.3f} "
|
||||
f"rise_m={dais_rise:.3f} minimum_m=1.000 "
|
||||
f"status={'PASS' if dais_rise >= 1.0 else 'FAIL'}", flush=True)
|
||||
if dais_rise < 1.0:
|
||||
raise RuntimeError(f"dais rises only {dais_rise:.3f} m above the chamber floor")
|
||||
dais_metrics = audit_shape("dais-platform", region_triangles(
|
||||
dais, 1.7, floor_top + 0.6, dais_top + 0.4),
|
||||
(dais[0], dais[1], dais_surface[0]))
|
||||
metrics_path = ARTIFACT_DIR / "landmark-shape-metrics.json"
|
||||
metrics_path.write_text(json.dumps({
|
||||
"graph_vocabulary_version": layout["cavern_graph_vocabulary_version"],
|
||||
"entry_m": entrance, "portal_m": portal, "entry_ground_m": ground_z,
|
||||
"chamber_center_m": chamber, "chamber_floor_m": floor_top,
|
||||
"tunnel_midpoint_m": tunnel_mid,
|
||||
"dais_center_m": dais, "dais_top_m": dais_top,
|
||||
"dais_surface_m": dais_surface[0], "dais_rise_m": dais_rise,
|
||||
"l37_thresholds": {"planar_patch_m2_lt": SHAPE.PLANE_LIMIT_M2,
|
||||
"straight_run_m_lte": SHAPE.STRAIGHT_LIMIT_M,
|
||||
"corner_turn_deg_lt": SHAPE.CORNER_TURN_LIMIT_DEG},
|
||||
"entrance_arch": entrance_metrics, "tunnel_wall": tunnel_metrics,
|
||||
"dais_platform": dais_metrics,
|
||||
"mesh_tiles": tile_count, "mesh_voxel_um": voxel_um,
|
||||
}, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[L48CaptureShapeMetrics] file={metrics_path} status=PASS", flush=True)
|
||||
@@ -0,0 +1,60 @@
|
||||
# Linux48 report
|
||||
|
||||
## Whole-surface audit
|
||||
|
||||
`AuditWholeSurfaceL48.py` scans every LOD0 triangle in the route corridor plus 3 m and the chamber box plus 3 m. It welds shared edges, compares geometric face normals, and scores area-weighted normal variation, high-angle edge density, maximum jumps and sliver area in 1 m cells. Connected hot cells become located clusters. A separate floor pass bins the full floor patch at 0.25 m along the route and 0.5 m across it, removes a local moving-mean trend, and checks ripple and adjacent steps. Thresholds use ground-facing native patches from the same export. JSON results and red-triangle XY maps are emitted.
|
||||
|
||||
The detector has no general quadric residual or periodic-spectrum metric yet; terraces outside its floor patch rely on the normal-cluster measures. The control results are:
|
||||
|
||||
| Mesh | Result | Evidence |
|
||||
|---|---|---|
|
||||
| L45 ramp | Expected fail | One normal cluster, 0.669 m² |
|
||||
| L46 entrance | Expected fail | 84.3 mm floor ripple; 159.1 mm largest step |
|
||||
| L47 wall-foot | Expected fail | One 5-cell cluster, 5.243 m², 42 severe edges; floor profile 34.5/57.6 mm ripple/step |
|
||||
| Plain native | Pass | No clusters; floor profile not present |
|
||||
| L47 smooth floor | Pass | 0.59 mm ripple; 1.15 mm largest step |
|
||||
| L48 candidate | **Fail** | Two clusters, 7.095 m² total, 65.5 severe edges, 303 flagged triangles; floor profile 34.5/57.6 mm |
|
||||
|
||||
The L47 cluster is at approximately `x=[-5,0], y=[22,25], z=[-505,-504] m`. At 0.188 m above the floor its wall-band p95 dihedral is 54.6° with a 160.4° maximum; the selected floor rails did not reach this band. The complete control output is in `Saved/Linux/l48/whole-surface-audit.json`.
|
||||
|
||||
## Teeth measurement and attempted fix
|
||||
|
||||
The L47 measurements localize the teeth to the floor-cap/bore-wall junction. The post-stamp cap has a hard radial cutoff there, and the floor-adjacent re-carve also uses a non-SDF weighted blend. A re-carve fade alone left the teeth in the capture. The final trial replaced both cavern cap gates with a rounded max of the sloped-floor distance and radial bore distance, restoring the L47 re-carve weight. This reduced the apparent tooth height in the capture but did not clear the whole-surface gate; the final mesh has more flagged area and a second cluster. The hard cap edge is implicated, but the cause is not proven and this is not a validated fix.
|
||||
|
||||
## Hashes and cost
|
||||
|
||||
Clang and GCC builds used source stamp `05ea28750b2ee89aa61655c386c6d74a6841c4d071374cda03c73a89b5efb578`.
|
||||
|
||||
Demo-off whole-view hashes match L47 exactly at W1 and W12:
|
||||
|
||||
- floating-islands: density `598e9daaa1744ccb`, mesh `3114e0f4f6dbf734`, payload `c49c4e48bff74089`, topology `cadd4edd10d4d8ef`.
|
||||
- surface-world: density `7dc75e2c09302258`, mesh `0cb34c40ceec4ce3`, payload `9d23e8c46b515cf1`, topology `2962c9464991eedd`.
|
||||
|
||||
Fast took 202.7 s. Medium took 637.6 s; the whole-surface stage took 161.8 s and failed on L48. Full and its five-pair cost comparison were not run because medium did not pass. All build and verification processes used `TMPDIR=Saved/Linux/l48/tmp`; no command wrote to `/tmp`.
|
||||
|
||||
## Tiers and captures
|
||||
|
||||
| Tier | Result |
|
||||
|---|---|
|
||||
| Fast | Pass, 202.7 s |
|
||||
| Medium | Fail, 637.6 s: L48 whole-surface gate |
|
||||
| Full | Not run: medium prerequisite failed |
|
||||
|
||||
The five captures are `ground-entrance.jpg`, `entrance-looking-in.jpg`, `overview-entrance-and-chamber.jpg`, `inside-to-dais.jpg`, and `entrance-floor-low.jpg` in `captures/`. Flag maps: `flagged-regions-L47-wall-foot-control.svg` and `flagged-regions-L48-final.svg` (red flagged triangles, gray ROI, blue route).
|
||||
|
||||
## Failed runs
|
||||
|
||||
- The first fast invocation had no built binary; after the Clang/GCC builds, fast passed.
|
||||
- Bypassing the post-stamp floor cap failed entrance rails. Broad SmoothMin/cap-mask trials either closed the mouth or failed the built-shape check; one planar mouth patch measured 5.575 m².
|
||||
- Radial re-carve fades at 0.5 m and 1 m preserved the rails but left visible teeth and failed the whole-surface audit. Changing the support inset had no mesh effect.
|
||||
- The final rounded-cap trial passed the legacy rails and shape checks, but failed medium with the clusters above. The 15-trial budget is exhausted. No full tier, commit, push or stash was performed.
|
||||
|
||||
## Orchestrator review (Claude)
|
||||
|
||||
Split. **The whole-surface audit is the result of this round,** and it's committed here as a tool, not yet wired into
|
||||
the tiers. All three historical defects fail it (L45 ramp, L46 entrance, L47 wall foot) and plain native terrain
|
||||
passes. It also shows that L47's floor near the walls carries 34.5 mm ripple and a 57.6 mm step, which the three
|
||||
rails never sampled. **The Core change (rounded cap) did not pass** and is parked with the tier wiring on
|
||||
`wip/l48-surface-audit`. Wiring the gate now would turn medium red on HEAD, because the flagged demo still has the
|
||||
teeth. `assembler/world` stays on L47 Core. L49 builds the floor and bore as one SDF, with a filleted floor/wall
|
||||
corner, before stamps, instead of patching with a post-pass cap.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,52 @@
|
||||
# R15 — Whole-surface artifact audit
|
||||
|
||||
## Key recommendations
|
||||
|
||||
1. Scan every LOD0 triangle in the exported region using three area-weighted metrics over overlapping surface patches: **detrended geometric residual**, **smoothed normal variation**, and **periodic-band coherence**. Group adjacent anomalous patches into clusters before failing the mesh.
|
||||
2. Calibrate each scale against plain native terrain exported by the same mesher at 25 cm. Choose thresholds on held-out clean regions, then lock them. The gate should pass clean references and reject **each of the five pre-fix defect meshes**; if one slips through, improve the detector rather than tune the threshold around that mesh.
|
||||
3. Add the offline check after mesh export in `verify.py`’s medium capture-and-audit stage. Report cluster coordinates and scores, plus a false-color mesh or marked capture. Full tier already reuses passed medium evidence; fast would need a small LOD0 export to include this gate.
|
||||
|
||||
## Metrics
|
||||
|
||||
For triangle \(f=(v_0,v_1,v_2)\), compute area \(A_f=\frac12\|(v_1-v_0)\times(v_2-v_0)\|\) and **geometric** face normal \(n_f\) from its positions. Use these normals for shape checks; stored smooth MC normals can hide or exaggerate geometric changes.
|
||||
|
||||
- **Detrended residual \(D_r\):** Fit a robust local quadric \(q_r\) over a geodesic neighborhood of radius \(r\), and measure the area-weighted RMS signed normal distance:
|
||||
\[
|
||||
D_r(P)=\sqrt{\frac{\sum_{v\in P}A_v\,\delta_r(v)^2}{\sum_{v\in P}A_v}},
|
||||
\qquad \delta_r(v)=(v-q_r(v))\cdot n_r(v).
|
||||
\]
|
||||
Also retain the 95th percentile and largest adjacent residual jump in the patch. This measures relief and steps without assuming the surface is a floor or wall.
|
||||
|
||||
- **Smoothed normal variation \(G_r\):** For adjacent faces \(f,g\), let \(\theta_{fg}=\cos^{-1}(n_f\cdot n_g)\) and \(d_{fg}\) be their geodesic center distance. Average normals over radius \(r\) first, then compute an area-weighted RMS of \(\theta_{fg}/d_{fg}\). This catches clustered creases and teeth while reducing single-triangle MC facet noise.
|
||||
|
||||
- **Periodic coherence \(C_\lambda\):** Take a spectrum or autocorrelation of \(\delta_r\) across overlapping surface patches, in all local tangent directions. Score the strongest narrow-band energy relative to neighboring frequencies, and require phase coherence across adjacent patches. Search wavelengths from roughly two to 32 voxels (0.5–8 m); use multiple window sizes so the longest periods have enough support. This detects repeated ribs, washboard, and seams without relying on a chosen rail.
|
||||
|
||||
Use all three as a small, interpretable suite. A raw dihedral histogram loses location and mixes ordinary MC facets with real creases. Discrete Gaussian curvature \(K_i=(2\pi-\sum_j\theta_{ij})/A_i^*\) and cotangent-Laplacian mean curvature are useful visual diagnostics, but raw one-ring estimates can be noisy on irregular MC triangles; prefer multi-scale smoothing or quadric fits. Triangle quality, such as \(2r/R\) or minimum altitude-to-edge ratio, is a useful secondary mesh-health check, not a primary artifact detector: ridges and staircases need not contain slivers.
|
||||
|
||||
MC linearly interpolates grid-edge crossings; the output can miss undersampled details, contain poor-shaped facets, and fail to preserve sharp features. That makes universal angle, curvature, or triangle-quality cutoffs unreliable. [CGAL’s isosurfacing manual](https://cgal.github.io/9465/doc/Isosurfacing_3/index.html) and [Dietrich et al. on MC triangle quality](https://doi.org/10.1109/TVCG.2008.60) document these limitations. [MeshLab](https://github.com/cnr-isti-vclab/meshlab/blob/main/docs/dox/filter.dox) and [libigl](https://libigl.github.io/tutorial/) provide useful curvature and quality measures; libigl also documents quadric curvature fitting. The specific three-metric combination above is a recommendation inferred from those methods and VoxelForge’s regressions, not a published universal detector.
|
||||
|
||||
## Reference and threshold method
|
||||
|
||||
Build a reference set from native-only LOD0 meshes using the same 25 cm mesher, recipes, and export path. Match patches by surface orientation and broad shape class; compare patch scores, not individual triangle scores. Use a held-out clean set to set per-scale thresholds from the distribution of **per-export maximum cluster scores** (with block bootstrap or equivalent uncertainty), so scanning more surface does not silently increase false alarms.
|
||||
|
||||
Threshold a connected cluster of adjacent anomalous patches, with a minimum footprint calibrated to the smallest visible defect. Ignore isolated single-triangle excursions unless a separate topology or fragment check catches them. Lock thresholds before scoring candidate fixes. Require every pre-fix defect mesh to fail individually and clean native references to pass. Keep all five bad meshes as fixtures; the native holdout guards against thresholds that simply reject rough terrain.
|
||||
|
||||
## Defect-to-metric map
|
||||
|
||||
| Known defect | Primary detector | Evidence and expected signal |
|
||||
|---|---|---|
|
||||
| **L35 wall ribs** | \(C_\lambda + G_r\) | The old wall has measured periods near 4.75 m in normals and 7.44 m in radius. A coherent band should cluster over the wall area. [L35 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux35/REPORT.md:5) |
|
||||
| **L36 floor washboard** | \(C_\lambda + D_r\) | The old floor repeats at 1.438 m and 7.250 m, with 2.25–2.48 cm amplitude. Whole-surface patches include the tread that the wall-only scan missed. [L36 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux36/REPORT.md:5) |
|
||||
| **L45 segmented ramp** | \(C_\lambda + D_r\) | Segment seams create repeated surface residuals along the ramp. The L45 control already fails its floor-ripple gate at 17.9 mm against a 15 mm limit. [L45 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux45/REPORT.md:42) [L46 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux46/REPORT.md:15) |
|
||||
| **L46 entrance staircase** | \(D_r\), including adjacent residual jump; \(G_r\) | The first 7 m measures 39.9–51.4 mm ripple and 81.8–82.9 mm residual steps. Detrending against the sloped floor separates the steps from the intended grade. [L47 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux47/REPORT.md:5) |
|
||||
| **L47 wall-foot sawteeth** | \(G_r + D_r\), clustered | Scan all connected faces, including the wall foot. The existing three floor rails do not reach it. [L47 report](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux47/REPORT.md:31) |
|
||||
|
||||
This three-metric set should catch all five while passing smooth native terrain **if** the matched reference and control gate confirm the separation. The reports establish the defects and measured signals; they do not establish final mesh-wide thresholds.
|
||||
|
||||
## Implementation and reporting
|
||||
|
||||
The existing `.vfmesh` readers expose tile LOD, voxel size, positions, normals, indices, and masks. Extend the tile-streaming approach used by [the ring audit](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py) and [the tile scanner](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Research/WorldAssemblerM3/Linux32/ScanMeshSurfaceTilesL32.py): analyze the whole target region at LOD0, include all face orientations, and weld duplicate vertices at same-LOD tile boundaries. Keep the audit offline on exported geometry.
|
||||
|
||||
The natural insertion point is after `CaptureLandmarkL47.py` and passage mesh exports in [`run_captures_and_mesh_audits()`](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Tools/verify/verify.py:651). That stage runs in medium and is reused by full; fast currently has no equivalent capture export. A fast-tier version would need a small targeted LOD0 export. [Voxel Tools’ smooth-terrain documentation](https://voxel-tools.readthedocs.io/en/latest/smooth_terrain/) notes that voxel LOD stitches can still show sharp steps, while [SAGA’s Terrain Ruggedness Index](https://saga-gis.sourceforge.io/saga_tool_doc/7.6.1/ta_morphometry_16.html) illustrates the value of neighborhood-scale terrain measures; its DEM assumptions do not cover arbitrary tunnel surfaces.
|
||||
|
||||
For each cluster, report world-space centroid, bounds, geodesic area and extent, triangle IDs, strongest metric, scale or wavelength, value, threshold, LOD, voxel size, and mesh/layout identity. Emit JSON plus a compact CSV, and a false-color mesh or marked image with cluster IDs and a reproducible camera pose. MeshLab’s face-quality coloring is one available review path.
|
||||