# SPDX-License-Identifier: Apache-2.0
"""Geometric review of the v01c pebble: openings, light windows, strip path, ports, controls, structure.

    ~/.venvs/goldfish-cad/bin/python v01/cad/review_geometry.py      → out_pebble/review.json

Reads layout.json (written by pebble_enclosure.py) and the analytic pebble; every number is measured, not assumed.
"""
from __future__ import annotations

import json
import math
from pathlib import Path

from pebble_layout import Spec

HERE = Path(__file__).parent
L = json.loads((HERE / "out_pebble" / "layout.json").read_text())
SPEC = Spec(**json.loads((HERE / "pebble_spec.json").read_text()))
pb = SPEC.pebble()
W = SPEC.wall
comps = {c["name"]: c for c in L["comps"]}
findings = []


def add(area, ok, what, detail):
    findings.append({"area": area, "ok": bool(ok), "what": what, "detail": detail})
    print(("PASS " if ok else "FLAG ") + f"[{area}] {what} — {detail}")


def ring_y(x, z, inset=0.0):
    """Half-width of the (inset) surface at height z for station x; 0 outside."""
    w, h = pb.half_width(x) - inset, (pb.top(x) if z >= 0 else pb.bot(x)) - inset
    n = pb.n_top if z >= 0 else pb.n_bot
    if w <= 0 or h <= abs(z):
        return 0.0
    return w * (1 - (abs(z) / h) ** n) ** (1 / n)


def loop_at(z, inset=0.0, N=800):
    xs = [-SPEC.L / 2 + SPEC.L * (0.5 - 0.5 * math.cos(math.pi * i / N)) for i in range(N + 1)]
    pts = [(x, ring_y(x, z, inset)) for x in xs]
    pts = [p for p in pts if p[1] > 0.3]
    return pts + [(x, -y) for x, y in reversed(pts)]


def curvature_radii(loop):
    out = []
    n = len(loop)
    for i in range(n):
        a, b, c = loop[i - 1], loop[i], loop[(i + 1) % n]
        ab, bc, ca = math.dist(a, b), math.dist(b, c), math.dist(c, a)
        cross = abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]))
        if cross > 1e-9 and min(ab, bc) > 0.05:
            out.append((ab * bc * ca / (2 * cross), b))
    return out


# ---------------------------------------------------------------- 1. openings inventory
ports = L["ports"]
strip = L.get("strip", {})
openings = [
    ("Light-ring openings", strip.get("n", 0), "all round the seam, behind the translucent TPU band (not visible as holes once the band is on)", "one per LED of the continuous strip"),
    ("Microphone ports", 2, "top shell, Ø1.0 mm, one near the head and one near the tail", "sound to the two bottom-port SPH0645 microphones"),
    ("Eye", 1, f"top, Ø{2 * L['eye']['r_out']:.0f} mm lens", "the single microphone-live light"),
    ("NFC icon", 1, "tail top, 0.4 mm skin (not through)", "lit 'tap here' mark; no hole"),
    ("USB-C", 1, "tail tip, with a plug pocket", "charging"),
    ("Power slider", 1, "belly-side flank below the band", "on/off"),
    ("Microphone slider", 1, "belly-side flank below the band", "hardware microphone cut"),
] + ([("Eye button", 0, "the eye itself: the light guide floats in its opening and presses a tactile switch below it", "press: room off the record; press again: back on; hold 2 s: arm the NFC tag (no extra hole)")]
       if SPEC.eye_button else [("Read button", 1, "belly-side flank below the band", "arms the NFC tag for ten seconds")]) + [
    ("Coin slot", 1, "nose, at the seam, outer 1.6 mm only", "pry point to open the shells"),
    ("Foot recess", 1, "base, 0.6 mm", "TPU foot (not through)"),
]
add("openings", True, "Inventory", "; ".join(f"{n} ×{k}" if k else f"{n} (no hole of its own)" for n, k, *_ in openings))

# ---------------------------------------------------------------- 2. light ring
leds = strip.get("leds", [])
if leds:
    zl = strip["z"]
    frac = strip["open_fraction"]
    add("structure", frac <= 0.45, "Shell opened at the light ring", f"{100 * frac:.0f} % of the run at LED height ({strip['n']} openings, 3.0 mm wide, 3.6 mm tall, {strip['pitch'] - 3.0:.1f} mm ribs); band covers them")
    worst = (0, None)
    for lx, ly, ang, ox, oy in leds:
        ya, yb = ring_y(lx, zl - 1.8), ring_y(lx, zl + 1.8)
        if ya and yb:
            t = math.degrees(math.atan2(ya - yb, 3.6))
            worst = max(worst, (t, round(lx)))
    add("ring", worst[0] <= 18, "Surface tilt across the openings", f"max {worst[0]:.0f}° from vertical (at x={worst[1]}); the seam is the most vertical line on the pebble")
    gaps = [math.dist(leds[i][:2], leds[i + 1][:2]) for i in range(len(leds) - 1)]
    add("ring", max(abs(g - strip["pitch"]) for g in gaps) <= 0.6, "Openings follow the strip pitch", f"LED-to-LED chord {min(gaps):.2f}–{max(gaps):.2f} mm vs 6.94 mm on the strip (chords shorten on the curves)")
    add("ring", strip["length_mm"] <= 1000, "Strip length", f"{strip['n']} LEDs, {strip['length_mm']:.0f} mm from one 1 m reel; starts and ends beside the USB-C pocket, no splices")
    for k in (3, 5, 7, 10):
        seg = strip["n"] // k
        add("ring", seg >= 3, f"Segment per person with {k} people", f"{seg} LEDs each (one kept dark as a divider → {seg - 1} lit steps)")

# ---------------------------------------------------------------- 3. strip path
z0b, z1b = L["info"]["light_band"]
zc = (z0b + z1b) / 2
strip_inset = W + 1.15 + 0.9
tilts = []
for lx, ly, *_ in leds:
    ya, yb = ring_y(lx, z0b, strip_inset), ring_y(lx, z1b, strip_inset)
    if ya and yb:
        tilts.append(math.degrees(math.atan2(ya - yb, z1b - z0b)))
if tilts:
    add("strip", max(tilts) <= 20, "Wall tilt across the strip width", f"max {max(tilts):.0f}°: the 7.5 mm strip lies on a land (the tongue) that is vertical by construction; the curved wall beyond it only sees the LED openings")

# strip vs parts: nothing may sit within 1.8 mm (strip 1.5 + 0.3) inside the land face, over the strip's height
if strip.get("land"):
    land = strip["land"]
    def inpoly(x, y):
        c = False
        for i in range(len(land)):
            (x1, y1), (x2, y2) = land[i - 1], land[i]
            if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
                c = not c
        return c
    def dseg(px, py, a, b):
        (x1, y1), (x2, y2) = a, b
        vx, vy = x2 - x1, y2 - y1
        t = max(0, min(1, ((px - x1) * vx + (py - y1) * vy) / ((vx * vx + vy * vy) or 1)))
        return math.hypot(px - x1 - t * vx, py - y1 - t * vy)
    zlo, zhi = strip["zone"]
    hits = []
    for c in L["comps"]:
        n = c["name"]
        if n.startswith(("USB-C", "LED", "mic-live")) or n.endswith("of USB-C breakout (charge port)"):   # the strip is cut for the port
            continue
        (x0, y0, z0), (sx, sy, sz) = c["at"], c["size"]
        if z0 > zhi or z0 + sz < zlo:
            continue
        worst = 9e9
        for i in range(9):
            for j in range(9):
                px, py = x0 + sx * i / 8, y0 + sy * j / 8
                d = min(dseg(px, py, land[k - 1], land[k]) for k in range(len(land)))
                worst = min(worst, d if inpoly(px, py) else -d)
        if worst < 1.8:
            hits.append(f"{n} ({worst:.1f} mm)")
    add("strip", not hits, "Parts clear of the strip", "all parts at strip height keep ≥ 1.8 mm from the land face" if not hits else "too close: " + ", ".join(hits))
    rr = []
    for i in range(1, len(leds) - 1):
        a_, b_, c_ = leds[i - 1][:2], leds[i][:2], leds[i + 1][:2]
        cr = abs((b_[0] - a_[0]) * (c_[1] - a_[1]) - (b_[1] - a_[1]) * (c_[0] - a_[0]))
        if cr > 1e-6:
            rr.append(math.dist(a_, b_) * math.dist(b_, c_) * math.dist(c_, a_) / (2 * cr))
    add("strip", min(rr) >= 5, "Strip bend radius along the real path", f"min {min(rr):.1f} mm (land corners are rounded to ≥ 10 mm by construction)")

# ---------------------------------------------------------------- 4. LEDs behind openings
add("ring", bool(leds), "LED behind its opening", "openings are cut at the LED positions computed along the strip, so the offset is 0 by construction" if leds else "no LED positions")

# ---------------------------------------------------------------- 5. microphones
for n in ("mic head (SPH0645)", "mic tail (SPH0645)"):
    m = comps[n]
    hx, hy = m["at"][0] + 8.25, m["at"][1] + (12.7 - 7.8)
    top_of_board = m["at"][2] + m["size"][2]
    # ADR-0018: the mic hangs on bosses; a printed port tube reaches down to it from the pin-hole
    tube_end = L.get("info", {}).get("mic_tubes", {}).get(n)
    ref, what = (tube_end, "port tube end") if tube_end is not None else (pb.top(hx) - W, "inner roof")
    gap = ref - top_of_board
    foam = round(gap * 1.35 * 2) / 2
    add("mics", 0.3 <= gap <= 2.0, f"{n.split(' (')[0]} seal gap", f"board top {top_of_board:.2f} mm, {what} {ref:.2f} mm → {gap:.2f} mm gap; seal with a {max(foam, 0.5):.1f} mm closed-cell foam ring (≈30 % compression)")
mh, mt = comps["mic head (SPH0645)"], comps["mic tail (SPH0645)"]
sep = abs((mh["at"][0] + 8.25) - (mt["at"][0] + 8.25))
add("mics", True, "Microphone spacing", f"{sep:.0f} mm apart along the length (two mics only: enough for voice activity and a coarse head/tail direction, not 360° placement)")

# ---------------------------------------------------------------- 6. controls
names = set(comps)
for need, label in (("power switch", "Power slide switch"), ("mic switch", "Microphone slide switch")) + ((("read switch (tactile)", "Read button tactile switch"),) if not SPEC.eye_button else ()):
    add("controls", need in names, f"{label} placed behind its opening", "in the layout, slot centred on its actuator" if need in names else "the shell has the opening but no part behind it")
for k, (cx, sy, cz, reach) in L.get("controls", {}).items():
    if "tactile" in k:
        add("controls", reach <= 6, "Read button plunger", f"printed plunger {reach:.1f} mm from the skin to the switch")
    else:
        if SPEC.kit_friendly:
            # ADR-0015: a fingertip needs ≥ 1.5 mm to push on (a bare 0.6 mm actuator needed a nail); the printed cap's head stands 1.8 mm proud
            cap = "power_cap" if k == "power switch" else "mic_cap"
            have = (HERE / "out_pebble" / f"{cap}.stl").exists()
            add("controls", have and 1.8 >= 1.5, f"{k.capitalize()} reachable with a fingertip", f"{reach:.1f} mm from switch face to skin; a printed cap ({cap}.stl) pushes onto the 4.0 mm actuator and stands 1.8 mm proud, "
                + ("smooth and round-ended, one raised dot above the slot" if k == "power switch" else "three ridges, two raised dots above the slot") + "; both turn on toward the head")
        else:
            add("controls", True, f"{k.capitalize()} actuator reach", f"{reach:.1f} mm from switch face to skin → printed slider cap (in slider_caps.stl) bridges it and stands 0.6 mm proud")

ctl = L.get("controls", {})
if "power switch" in ctl and "mic switch" in ctl:
    sep = abs(ctl["power switch"][0] - ctl["mic switch"][0])
    add("controls", sep >= 25, "Power and mic switches told apart", f"{sep:.0f} mm apart along the flank, different cap shapes, one dot vs two (target ≥ 25 mm)")
# ADR-0015: posts reach the roof; none may land in the eye's opening, flange or cradle hangers
xe = L.get("eye", {}).get("x")
if not any(c["name"].startswith("post ") for c in L["comps"]):
    add("structure", True, "Board posts clear of the eye", "no posts hang from the roof: the Radxa stands on the battery deck (ADR-0020), so nothing reaches down near the eye")
elif xe is not None:
    dmin = min(math.hypot(c["at"][0] + c["size"][0] / 2 - xe, c["at"][1] + c["size"][1] / 2) for c in L["comps"] if c["name"].startswith("post"))
    n_posts = sum(1 for c in L["comps"] if c["name"].startswith("post"))
    from pebble_layout import EYE_POST_CLEAR
    add("structure", dmin >= EYE_POST_CLEAR, "Board posts clear of the eye", f"nearest post centre {dmin:.1f} mm from the eye axis (≥ {EYE_POST_CLEAR:.1f}: flange r 11 + hanger margin + post radius); the board hangs on {n_posts} posts")
ov = L.get("info", {}).get("shell_overlap_mm3")
if ov is not None:
    add("structure", ov < 1.0, "Shells don't overlap", f"top ∩ bottom = {ov:.1f} mm³ (the tongue is clipped to the top shell's cavity less the 0.15 mm fit)")
cs = L.get("info", {}).get("coin_slot")
if cs:
    add("structure", cs[1] >= 2.4 and cs[0] >= 10, "Coin slot takes a coin", f"{cs[0]:.0f} × {cs[1]:.1f} mm across the seam: euro and US coins are 1.35–2.33 mm thick")
# ADR-0016: a post that meets the roof at a sharp corner snaps there, and a print's layers part there first
fl = L.get("info", {}).get("post_flares")
n_posts = sum(1 for c in L["comps"] if c["name"].startswith("post"))
dp_ = L.get("info", {}).get("deck_posts", [])
if fl is not None and not fl and dp_:
    # ADR-0020: no posts hang from the roof; the posts with inserts are the deck's, rooted in the floor with the same
    # flare (R 2 into 45°, 1.6 mm wide), wide end at the floor: probe 0.6 mm above each root, halfway into the flare
    import trimesh
    bs_ = trimesh.load(HERE / "out_pebble" / "bottom_shell.stl")
    from pebble_layout import POST_D as POST_D_, POST_FLARE_OUT
    rp = POST_D_ / 2 + (POST_FLARE_OUT - 0.6) / 2
    probes, missing = 0, []
    for cx_, cy_, cz_ in dp_:
        pts = [(cx_ + rp * math.cos(t), cy_ + rp * math.sin(t), cz_ + 0.6) for t in (2 * math.pi * k / 36 for k in range(36))]
        ins = bs_.contains(pts)
        probes += int(ins.sum())
        if not ins.all():
            missing.append(f"({cx_:.0f}, {cy_:.0f})")
    add("structure", not missing, "Post roots flared",
        f"no posts hang from the roof (ADR-0020); the battery deck's {len(dp_)} floor posts have flared roots, found in the printed mesh at {probes}/{36 * len(dp_)} probe points. "
        "The 1.2 m drop decides their strength (B-43)" + (f"; missing at {', '.join(missing)}" if missing else ""))
elif fl is not None:
    import trimesh
    shell = trimesh.load(HERE / "out_pebble" / "top_shell.stl")
    probes, missing = [], []
    for f in fl:   # 0.6 mm below the lowest root point the flare reaches r + out − 0.6: probe halfway out to that, all round
        rp = f["r"] + (f["out"] - 0.6) / 2
        pts = [(f["c"][0] + rp * math.cos(t), f["c"][1] + rp * math.sin(t), f["z_ref"] - 0.6) for t in (2 * math.pi * k / 36 for k in range(36))]
        ins = shell.contains(pts)
        probes.append(int(ins.sum()))
        if not ins.all():
            missing.append(f["post"])
    ok = len(fl) == n_posts and not missing and min(f["R"] for f in fl) >= SPEC.wall / 2
    add("structure", ok, "Post roots flared",
        f"{len(fl)}/{n_posts} posts: R {min(f['R'] for f in fl):.1f} mm fillet into a 45° flare {min(f['out'] for f in fl):.1f} mm wide at the roof "
        f"(found in the printed mesh at {sum(probes)}/{36 * len(fl)} probe points); uphill collars up to {max(f['collar_mm'] for f in fl):.1f} mm where the roof slopes. "
        f"R ≥ wall/2 ({SPEC.wall / 2:.1f} mm) is a rule of thumb, not a strength test: the 1.2 m drop decides (B-43)"
        + (f"; missing on {', '.join(missing)}" if missing else ""))
else:
    add("structure", False, "Post roots flared", "layout.json has no post_flares: rerun cad/pebble_enclosure.py")
# every keep-out box and fastener ("<what> of <owner>": a post's flared root, the icon's light path, an insert, a screw
# head) is free of every part but its owner. Parts of one owner (a post's insert and its flare) may share space.
keep = [c for c in L["comps"] if c.get("role") in ("keep-out", "fastener")]
hits = []
for k in keep:
    owner = k["name"].split(" of ", 1)[1]
    for c in L["comps"]:
        if c is k or c["name"] == owner or c["name"].endswith(f" of {owner}"):   # the owner and its own bosses, heads, strap
            continue
        if owner == "radxa pcb" and c["name"].startswith("radxa "):   # the board's own keep-out boxes; its 3D model is the real test (buildability.py)
            continue
        if k["name"].startswith("boss ") and (c["name"] == "battery deck" or c["name"].startswith("deck tab")):   # a boss on the deck roots into it by design
            continue
        if all(k["at"][i] < c["at"][i] + c["size"][i] and c["at"][i] < k["at"][i] + k["size"][i] for i in range(3)):
            hits.append(f"{c['name']} in the {k['name']}")
add("structure", bool(keep) and not hits, "Keep-outs and fasteners clear of parts",
    f"{len(keep)} boxes ({', '.join(sorted({k['name'].split(' of ')[0] for k in keep}))}) hold no part but their owner" if not hits else "touching: " + "; ".join(hits))
# ADR-0017: the insert boss, sized from the insert's datasheet
from pebble_layout import INSERT_DEPTH, INSERT_HOLE, INSERT_L, INSERT_WALL, POST_D, SCREW_L, WASHER_T
posts = [c for c in L["comps"] if c["name"].startswith("post ")]
fl_by = {f["post"]: f for f in (fl or [])}
short = [p["name"] for p in posts if p["name"] in fl_by and fl_by[p["name"]]["post_len"] < INSERT_L]
engage = SCREW_L - 1.6 - WASHER_T
if not posts:   # ADR-0020: the inserts are in the deck's floor posts; the deck tab (2 mm) is what the screw clamps
    from pebble_layout import DECK_T
    fl_by = {c["name"]: {"post_len": c["size"][2]} for c in L["comps"] if c["name"].startswith("deck post")}
    short = [n for n, f in fl_by.items() if f["post_len"] < INSERT_DEPTH]
    engage = SCREW_L - DECK_T
add("structure", (POST_D - INSERT_HOLE) / 2 >= INSERT_WALL and not short and 2.5 <= engage <= INSERT_L, "Heat-set insert bosses",
    f"Ø{INSERT_HOLE:.1f} mm bore {INSERT_DEPTH:.1f} mm deep (insert {INSERT_L} mm + 1) in a Ø{POST_D:.1f} mm post: {(POST_D - INSERT_HOLE) / 2:.1f} mm wall (datasheet minimum {INSERT_WALL}); "
    f"shortest post {min(f['post_len'] for f in fl_by.values()):.1f} mm to its lowest root point; the {SCREW_L:.0f} mm screw engages {engage:.1f} mm of thread. "
    "Printed holes come out ≈ 0.25 mm small (CNC Kitchen): print the test coupon first" + (f"; too short for the insert: {', '.join(short)}" if short else ""))
# ADR-0017: the icon's skin, measured in the printed mesh down through the middle arc and the dot
icon, shape = L.get("nfc_icon"), L.get("nfc_icon_shape")
if icon and shape:
    import trimesh
    m = trimesh.load(HERE / "out_pebble" / "top_shell.stl")
    skins = []
    for x, y in ((icon[0] + shape["arcs"][1] + shape["w"] / 2, icon[1]), (icon[0] + shape["dot_dx"], icon[1])):
        locs, _, _ = m.ray.intersects_location([[x, y, 60.0]], [[0, 0, -1.0]])
        zs = sorted((p[2] for p in locs), reverse=True)
        if len(zs) >= 2:
            skins.append(zs[0] - zs[1])
    ok = len(skins) == 2 and min(skins) >= 0.33
    add("ring", ok, "Tap icon skin", f"{min(skins):.2f}–{max(skins):.2f} mm of PETG between the deboss and the pocket (target {shape['skin']} mm: three 0.12 mm layers; "
        "thinner pinholes, thicker dims the LED)" if skins else "no skin found under the icon")
lc = L.get("info", {}).get("lip_chamfer_mm3")
# measured on the printed top shell: probe the lip all round, 0.1 mm above the seam and 0.2 mm into the wall from
# the cavity; a 0.4 × 45° chamfer has removed the plastic there (at that height it reaches 0.3 mm in)
if isinstance(lc, list):
    import trimesh
    from pebble import inside as _inside
    ts = trimesh.load(HERE / "out_pebble" / "top_shell.stl")
    zp = L["z_split"] + 0.1
    pts = []
    for k in range(360):
        a_ = 2 * math.pi * k / 360
        lo_, hi_ = 0.0, SPEC.L / 2
        for _ in range(30):                       # the cavity's edge along this ray, at the probe height
            mid = (lo_ + hi_) / 2
            if _inside(pb, mid * math.cos(a_), mid * math.sin(a_), zp, W): lo_ = mid
            else: hi_ = mid
        pts.append(((lo_ + 0.2) * math.cos(a_), (lo_ + 0.2) * math.sin(a_), zp))
    solid_ = ts.contains(pts)
    frac = 1 - solid_.mean()
    add("structure", frac >= 0.95, "Lead-in on the top shell's lip",
        f"0.4 × 45° chamfer round the inner lip, grown from the lofted cavity: {100 * frac:.0f} % of 360 probes 0.2 mm into the wall, 0.1 mm above the seam, find it cut "
        f"({lc[0]} mm³ removed). The seam edge prints on the bed, and its squashed first layers would otherwise catch the tongue")
else:
    add("structure", False, "Lead-in on the top shell's lip", "no chamfer in layout.json")
# ADR-0018: every board screwed down (captive-nut bosses) or strapped, so the prototype comes apart and goes back together
from pebble_layout import BOSS_H
BOARD_ROLES = ("power", "security", "time", "touch", "handoff", "ears")
boards_ = [c for c in L["comps"] if c.get("role") in BOARD_ROLES and not c["name"].startswith(("power switch", "mic switch", "read switch", "charger DC jack"))
           or c["name"].startswith("power switch Pololu")]
loose = [c["name"] for c in boards_ if not c.get("mount") and c["name"] != "battery"]
mt = L.get("info", {}).get("mounts", [])
n_nut = sum(1 for c in boards_ if c.get("mount", {}).get("kind") == "nut")
n_strap = sum(1 for c in boards_ if c.get("mount", {}).get("kind") == "strap")
add("structure", not loose and bool(mt), "Every board screwed down",
    f"{n_nut} boards on their own holes and {n_strap} under printed straps, {len(mt)} captive M2.5 nuts in all, plus the Radxa on its posts and the cell under the screwed deck; "
    "one screw everywhere (M2.5 × 6), so it comes apart for any number of rebuilds" if not loose else "loose: " + ", ".join(loose))
thin = [f"{x['board']} at {x['at']}" for x in mt if x["skin_mm"] < 0.5]
add("structure", bool(mt) and not thin, "Screw tips stay in the plastic",
    f"every blind screw hole leaves ≥ 0.5 mm: thinnest {min(x['skin_mm'] for x in mt):.2f} mm to the outer skin, or above the deck's underside (the cell is below it)" if mt and not thin else "too thin: " + "; ".join(thin))
short = [f"{x['board']} at {x['at']} ({x['boss_mm']} mm)" for x in mt if x["boss_mm"] < BOSS_H - 0.05]
add("structure", bool(mt) and not short, "Bosses hold their nuts",
    f"every boss ≥ {BOSS_H} mm (0.8 mm cap, 2.3 mm nut pocket with a side slot, 0.4 mm below); shortest {min(x['boss_mm'] for x in mt):.1f} mm" if mt and not short else "too short: " + "; ".join(short))
hb = L.get("info", {}).get("heat_boss_mm")
if hb is not None:
    add("thermal", abs(hb) <= 1.0, "Heat stack squeeze", f"1.5 mm pad + 6 mm heatsink + 4.5 mm pad (three 1.5 mm layers, ADR-0020) squeezed 0.5 mm by a {'boss' if hb > 0 else 'pocket'} of {abs(hb):.2f} mm on the roof; soft pads (55 Shore 00) keep the load on the board to a few newtons")

# ---------------------------------------------------------------- wiring (cad/wire_routes.py)
WJ = HERE / "out_pebble" / "wires.json"
if WJ.exists():
    W = json.loads(WJ.read_text())["summary"]
    add("wiring", not W["unrouted"], "Every wire routes through the closed case", f"{W['routed']}/{W['wires']} wires of wiring.md in {W.get('runs', '?')} runs, pad to pad, {W['total_length_mm']:.0f} mm in all, around every part and clear of the light strip"
        + (f"; unrouted: {', '.join(W['unrouted'])}" if W["unrouted"] else ""))
    add("wiring", W["tight_voxels"] == 0, "Wire bundles fit", f"the fattest bundle is {W['max_bundle_30awg_equiv']} × 30 AWG in area; worst squeeze {max(W['worst_overlap_mm'], 0):.2f} mm past the free space around it, within the 0.5 mm grid tolerance (silicone wire gives)"
        + (f"; tight at {W['tight_at'][:3]}" if W["tight_voxels"] else ""))
    if "unstowed" in W:   # ADR-0020: the spare on each run to the lid, folded into a loop that fits in the closed case
        add("wiring", not W["unstowed"], "Spare wire stows in the closed case",
            f"{W['loops']} service loops by the lid connector hold the length each run to the lid needs to open it flat; each sits in free space, "
            "clear of parts and other wires, and folds the run's wires together" if not W["unstowed"] else "no room for: " + ", ".join(W["unstowed"]))
    add("wiring", bool(W["seam_wires"]), "Wires across the seam are cut long enough to open the shells", f"{len(W['seam_wires'])} runs ({W.get('seam_conductors', '?')} wires) join the chassis to the lid connector; each is cut to reach with the lid laid open (straight line to the turned end + 15 %, + 10 %; cut list in wiring.md), longest route {W['longest_seam_mm']:.0f} mm")
else:
    add("wiring", False, "Wires routed", "run cad/wire_routes.py")

if SPEC.eye_button:
    sw = comps.get("eye switch (tactile, in cradle)")
    add("controls", sw is not None, "Eye button switch under the guide", "6 × 6 × 4.3 mm through-hole tactile switch in a cradle hung from the roof on three arms" if sw else "no switch in the layout")
    if sw:
        gap = L["eye"]["led_top"] - (sw["at"][2] + sw["size"][2])
        add("controls", -0.1 <= gap <= 0.3, "Eye press reaches the switch", f"guide underside to switch top {gap:.2f} mm ({'preloaded' if gap < 0 else 'gap'}), then 0.25 mm switch travel → a short, firm press with no rattle; the flange stops the guide falling out")
        x0, y0, z0 = sw["at"]; sx_, sy_, sz_ = sw["size"]
        clash = [n for n, c in comps.items() if n != sw["name"] and c.get("group") != "eye" and
                 c["at"][0] < x0 + sx_ and x0 < c["at"][0] + c["size"][0] and c["at"][1] < y0 + sy_ and y0 < c["at"][1] + c["size"][1] and c["at"][2] < z0 + sz_ and z0 < c["at"][2] + c["size"][2]]
        add("controls", not clash, "Eye switch clear of other parts", "no overlap with the boards below" if not clash else "overlaps " + ", ".join(clash))

# ---------------------------------------------------------------- 7. USB-C
u = comps["USB-C breakout (charge port)"]
mouth = u["at"][0]
tip = -L["outer"][0] / 2
add("usb", True, "USB-C plug engagement", f"receptacle mouth {mouth - tip:.1f} mm inside the tail tip; a 13.2 × 7.4 mm pocket runs to the mouth so a standard overmould (≈12.4 × 6.5) seats fully")
pocket_hw = 13.2 / 2
wall_left = ring_y(mouth + 1, u["at"][2] + 2.7) - pocket_hw
add("usb", wall_left >= 1.2, "Wall beside the plug pocket", f"{wall_left:.1f} mm each side at the mouth")

# ---------------------------------------------------------------- 8. thermal and drop
area = 0.0
outer_mid = loop_at(0)
per0 = sum(math.dist(outer_mid[i], outer_mid[(i + 1) % len(outer_mid)]) for i in range(len(outer_mid)))
area = (2 * 0.82 * SPEC.L * SPEC.W + per0 * SPEC.T * 0.6) / 1e6
for p_w in (1.0, 2.5):
    dT = p_w / (8.0 * area)
    add("thermal", dT <= 20, f"Surface rise at {p_w} W", f"≈ {dT:.0f} K over ambient (natural convection h ≈ 8 W/m²K, {area * 1e4:.0f} cm²); PETG softens at ~75 °C")
de = L["mass_g"] / 1000 * 9.81 * 1.0   # m g h at 1 m (layout.json tabulates 0.75, 1.2 and 1.5 m only; reading its first entry printed the 0.75 m value)
add("drop", True, "Drop energy from 1 m", f"{de:.2f} J at {L['mass_g']:.0f} g (a phone carries about the same); the TPU band takes the edge hit and the walls are ≥ 2.0 mm. Geometry can't pass or fail this: the 1.2 m drop test does (requirement D-01, backlog B-43). The earlier 1.5 J line was set at 133 g and is retired")

out = HERE / "out_pebble" / "review.json"
out.write_text(json.dumps({"openings": [dict(name=n, count=k, where=w, purpose=p) for n, k, w, p in openings], "findings": findings}, indent=1))
print(f"{sum(f['ok'] for f in findings)}/{len(findings)} pass → {out}")
