Files
VoxelForge/Standalone/Research/WorldAssemblerM3/Linux35/AuditPassageMouthMeshProfilesL35.py
T
Fr0zkaandClaude Opus 5.5 ba017077d1 wip(assembler): round L35 -- smooth passage walls without regular ribs (evaluator v21)
The shared passage carve took the minimum of piecewise-linear capsules (scallops at joins) and added
wall relief on regular 8 m / 4 m octaves, which read as evenly spaced ribs. Interior sweep joins are
now Catmull-Rom smoothed and the relief uses irregular rotated 43/29/19 m scales; the natural wobble
remains. New axial ripple audit (0.125 m radius samples, 0.5-8 m periods): beaded control and L34
geometry fail, all 24 passage rows pass. FI mouth tread apron tapered. 30/30 determinism, 16/16
invariants, ring audits LOD0-8 pass; cost within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX
2026-10-22 07:28:50 +02:00

252 lines
11 KiB
Python

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