wip(assembler): round L34 -- passage mouth profile audit and standard capture set
Adds a density-field floor/wall profile audit along passages near mouths (0.125 m spacing; L23 terraced negative control detected) and a reusable 12-view passage capture set (80 deg FOV, eye height, LOD0, skirts off, capped clip planes). The old blurry mouth shot was camera framing; the L33 entry ledges were tile skirts. Owner world hashes unchanged; 24/24 passage rows, 30/30 determinism, 16/16 invariants; cost within noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX
This commit is contained in:
1 parent
b7a47bef95
commit
49e29fcd36
24 files changed
+1863
-3
No files matched your search
@@ -0,0 +1,251 @@
|
||||
#!/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/l34"
|
||||
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"[L34MouthMeshProfile] 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"[L34MouthMeshProfileSummary] 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)
|
||||
Reference in new issue
Block a user