#!/usr/bin/env python3 """Compare the FI fade-ring crease against L35 using the same surface samples.""" import argparse import importlib.util import math import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[4] RING_PATH = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py" SPEC = importlib.util.spec_from_file_location("ring_profile_l32", RING_PATH) RING = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(RING) CENTER = (6.298233, 1.137233) INNER, OUTER, STEP = 5.5, 9.5, 0.125 HEIGHT_MIN, HEIGHT_MAX, PREFERRED = -80.0, -40.0, -56.0 NORMAL_LIMIT_DEG = 35.0 COVERAGE_MIN = 0.90 FRAGMENT_MAX_TRIANGLES = 64 FRAGMENT_MIN_AREA_M2 = 0.0001 FRAGMENT_MAX_AREA_M2 = 0.05 FRAGMENT_MAX_DISTANCE_M = 12.0 def small_components(triangles): parent = list(range(len(triangles))) sizes = [1] * len(triangles) owners = {} def find(index): while parent[index] != index: parent[index] = parent[parent[index]] index = parent[index] return index def union(a, b): a, b = find(a), find(b) if a == b: return if sizes[a] < sizes[b]: a, b = b, a parent[b] = a sizes[a] += sizes[b] for index, (_, _, vertices) in enumerate(triangles): for point, _ in vertices: key = tuple(round(value, 5) for value in point) other = owners.setdefault(key, index) union(index, other) components = {} for index, (_, _, vertices) in enumerate(triangles): root = find(index) item = components.setdefault(root, {"count": 0, "area": 0.0, "sum": [0.0] * 3}) item["count"] += 1 points = [vertex[0] for vertex in vertices] for point in points: for axis in range(3): item["sum"][axis] += point[axis] ab = tuple(points[1][axis] - points[0][axis] for axis in range(3)) ac = tuple(points[2][axis] - points[0][axis] for axis in range(3)) cross = (ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]) item["area"] += 0.5 * math.sqrt(sum(value * value for value in cross)) cx, cy = CENTER small = [] below_floor = [] for item in components.values(): centroid = tuple(value / (3 * item["count"]) for value in item["sum"]) distance = math.hypot(centroid[0] - cx, centroid[1] - cy) if (item["count"] <= FRAGMENT_MAX_TRIANGLES and item["area"] <= FRAGMENT_MAX_AREA_M2 and distance <= FRAGMENT_MAX_DISTANCE_M): record = (item["count"], item["area"], distance, centroid) (below_floor if item["area"] < FRAGMENT_MIN_AREA_M2 else small).append(record) return len(components), small, below_floor def audit(path): cx, cy = CENTER bounds = (cx - OUTER, cx + OUTER, cy - OUTER, cy + OUTER) voxel_um, triangles, tiles = RING.read_scene( str(path), 0, HEIGHT_MIN, HEIGHT_MAX, bounds) component_count, fragments, below_floor = small_components(triangles) print(f"[MouthFragmentComponents] mesh={path.name} components={component_count} " f"small_near_mouth={len(fragments)} subthreshold_near_mouth={len(below_floor)} " f"limits=(triangles<={FRAGMENT_MAX_TRIANGLES}," f"area={FRAGMENT_MIN_AREA_M2:.4f}..{FRAGMENT_MAX_AREA_M2:.3f}m2," f"distance<={FRAGMENT_MAX_DISTANCE_M:.1f}m) " f"first={fragments[0] if fragments else None} " f"subthreshold={below_floor[0] if below_floor else None} " f"status={'PASS' if not fragments else 'FAIL'}") cell = max(2.0, 16.0 * voxel_um / 1_000_000.0) buckets = RING.build_buckets(triangles, True, bounds, cell) lines = [] count = round((OUTER - INNER) / STEP) for degrees in (135.0, 180.0, 225.0, 270.0, 315.0): angle = math.radians(degrees) lines.append((f"radial-{degrees:.0f}", [ (cx + radius * math.cos(angle), cy + radius * math.sin(angle)) for radius in (INNER + i * STEP for i in range(count + 1))])) tangent_count = round(8.0 / STEP) for degrees in (225.0, 315.0): angle = math.radians(degrees) normal = (math.cos(angle), math.sin(angle)) tangent = (-normal[1], normal[0]) lines.append((f"tangent-{degrees:.0f}", [ (cx + 6.5 * normal[0] + (-4.0 + i * STEP) * tangent[0], cy + 6.5 * normal[1] + (-4.0 + i * STEP) * tangent[1]) for i in range(tangent_count + 1)])) max_angle = 0.0 max_pair = None min_coverage = 1.0 for name, points in lines: samples = [RING.sample_surface(buckets, x, y, True, cell, PREFERRED) for x, y in points] coverage = sum(value is not None for value in samples) / len(samples) min_coverage = min(min_coverage, coverage) line_max = 0.0 for index in range(1, len(samples)): prior, current = samples[index - 1], samples[index] if prior is None or current is None: continue dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(prior[1], current[1])))) delta = math.degrees(math.acos(dot)) if delta > line_max: line_max = delta if delta > max_angle: max_angle = delta max_pair = (name, points[index - 1], prior[0], points[index], current[0]) print(f"[MouthFadeLine] mesh={path.name} line={name} coverage={coverage:.3f} " f"max_normal_jump_deg={line_max:.6f}") normal_pass = max_angle <= NORMAL_LIMIT_DEG and min_coverage >= COVERAGE_MIN passed = normal_pass and not fragments print(f"[MouthFadeAudit] mesh={path.name} tiles={tiles} lines={len(lines)} " f"coverage_min={min_coverage:.3f} coverage_limit={COVERAGE_MIN:.2f} " f"max_normal_jump_deg={max_angle:.6f} normal_limit_deg={NORMAL_LIMIT_DEG:.1f} " f"fragments={len(fragments)} " f"worst_pair={max_pair} status={'PASS' if passed else 'FAIL'}") return passed, max_angle def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--control", type=Path, default=ROOT / "Saved/Linux/l35/owner-fi-ground-mouth.vfmesh") parser.add_argument("--current", type=Path, default=ROOT / "Saved/Linux/l36/owner-fi-ground-mouth.vfmesh") args = parser.parse_args() control = args.control if args.control.is_absolute() else ROOT / args.control current = args.current if args.current.is_absolute() else ROOT / args.current control_ok, control_max = audit(control) current_ok, current_max = audit(current) expected = not control_ok and current_ok and control_max > NORMAL_LIMIT_DEG print(f"[MouthFadeNegativeControl] control_max_deg={control_max:.6f} " f"control_detected={not control_ok} current_max_deg={current_max:.6f} " f"status={'PASS' if expected else 'FAIL'}") raise SystemExit(0 if expected else 1) if __name__ == "__main__": main()