# SPDX-License-Identifier: Apache-2.0
"""Can a builder who orders every part actually build the kit? Walks the build the way the assembly guide does.

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

Reads layout.json, wires.json, print.json, bom.csv, print_spec.json, assembly.md and the printed meshes. Each check
says what a builder would hit: a part with no row in the BOM, a screw no driver can reach, a nut slot that opens into a
wall, a component sitting where a boss lands, a cable too short to open the case, a part that won't fit the bed.
"""
from __future__ import annotations

import csv
import json
import math
import re
from pathlib import Path

import numpy as np
import trimesh

from pebble_layout import BOSS_CAP, BOSS_D, NUT_M, SCREW_HEAD_D, Spec, shell_of

HERE = Path(__file__).parent
OUT = HERE / "out_pebble"
V01 = HERE.parent
L = json.loads((OUT / "layout.json").read_text())
W = json.loads((OUT / "wires.json").read_text())
PRINT = json.loads((OUT / "print.json").read_text())
PSPEC = json.loads((HERE / "print_spec.json").read_text())
SPEC = Spec(**json.loads((HERE / "pebble_spec.json").read_text()))
BOM = list(csv.DictReader(open(V01 / "bom.csv", newline="")))
ASSEMBLY = (V01 / "assembly.md").read_text()
COMPS = {c["name"]: c for c in L["comps"]}
ZS = L["z_split"]
results = []


def add(area, ok, what, detail):
    results.append({"area": area, "ok": bool(ok), "what": what, "detail": detail})


def is_part(c):
    return c.get("role") not in ("keep-out", "fastener") and " of " not in c["name"] and not c["name"].startswith(("radxa ", "deck ", "post "))


def shell(c):
    s = shell_of(c, ZS, COMPS)
    if s is None:                                      # owned item: its owner's shell
        s = shell_of(COMPS[c["name"].split(" of ", 1)[1]], ZS, COMPS)
    return s


# ---------------------------------------------------------------- 1. every part in the model is bought (or printed)
BUY = {   # layout part → a string its BOM row contains, and how many the layout uses of it
    "radxa pcb": "ZERO 3E", "battery": "4000 mAh", "USB-C breakout (charge port)": "4090", "bq24074 charger (Adafruit 4755)": "4755",
    "mic head (SPH0645)": "SPH0645", "mic tail (SPH0645)": "SPH0645", "NFC tag ST25DV64KC": "ST25DV64KC", "NFC icon LED (NeoPixel Mini)": "1612",
    "RTC PCF8523 + CR1220 (STEMMA QT)": "5189", "ATECC608 secure element": "4314", "vibration disc (ERM)": "1201", "5 V boost U3V40F5": "U3V40F5",
    "power switch Pololu 2810": "2810", "ideal diode LM66200 (Adafruit 5830)": "5830", "power switch": "OS102011MA1QN1", "mic switch": "OS102011MA1QN1",
    "eye switch (tactile, in cradle)": "DTS-61K", "mic-live dot (sequin)": "1758", "SoC thermal pad (to the roof)": "Heatsink",
    "lid connector (12-way)": "G125",
}


def bom_rows(key):
    return [r for r in BOM if key in " ".join(r.values())]


def bom_qty(rows):
    q = 0
    for r in rows:
        m = re.match(r"\s*(\d+)", r.get("qty") or "")
        q += int(m.group(1)) if m else 1
    return q


missing, short = [], []
need = {}
for name, key in BUY.items():
    if name in COMPS:
        need[key] = need.get(key, 0) + 1
for c in L["comps"]:
    if is_part(c) and c["name"] not in BUY and not c["name"].startswith(("battery deck", "LED")):
        missing.append(f"{c['name']} (no BOM key)")
for key, n in need.items():
    rows = bom_rows(key)
    if not rows:
        missing.append(key)
    elif bom_qty(rows) < n:
        short.append(f"{key}: BOM {bom_qty(rows)}, layout {n}")
add("bom", not missing and not short, "Every part in the model is in the BOM",
    f"{len(need)} bought parts, {sum(need.values())} in the layout, each with a row and enough quantity" if not missing and not short
    else "; ".join(["not in the BOM: " + ", ".join(missing)] * bool(missing) + ["short: " + ", ".join(short)] * bool(short)))

prows = {r["sku"]: r for r in BOM if r["ref"].startswith("P")}
noprint = [n for n in PRINT if f"{n}.stl" not in prows or n not in PSPEC["parts"]]
add("bom", not noprint, "Every print file has a BOM row and print settings",
    f"{len(PRINT)} print files, each in bom.csv (P-rows) and print_spec.json" if not noprint else "missing for: " + ", ".join(noprint))

# fasteners: what the build uses against what the BOM buys
mounts = L["info"].get("mounts", [])
n_posts = sum(1 for c in L["comps"] if c["name"].startswith("post "))
n_deck = len(L["info"].get("deck_posts", []))
screws, nuts, inserts = len(mounts) + n_deck + n_posts, len(mounts), n_posts + n_deck
kit = next((r for r in BOM if "screw and nut kit" in r["part"]), None)
kit_s = int(re.search(r"at least (\d+) × M2\.5 × 6", kit["part"]).group(1)) if kit else 0
kit_n = int(re.search(r"(\d+) hex nuts", kit["part"]).group(1)) if kit else 0
ins = next((r for r in BOM if "heat-set inserts" in r["part"]), None)
ins_q = bom_qty([ins]) if ins else 0
straps = sum(1 for c in L["comps"] if c["name"].startswith("strap of "))
foam = next((r for r in BOM if r["part"].startswith("Closed-cell foam pads")), None)
foam_n = int(re.search(r"each of the (\d+) board straps", foam["part"]).group(1)) if foam and "each of the" in foam["part"] else -1
ok_f = kit_s >= screws and kit_n >= nuts and ins_q >= inserts and foam_n == straps
add("bom", ok_f, "Fasteners bought cover the fasteners used",
    f"screws: {screws} used ({len(mounts)} into nuts, {n_deck} deck, {n_posts} board posts), kit ≥ {kit_s}; nuts: {nuts} used, kit ≥ {kit_n}; "
    f"heat-set inserts: {inserts} used, BOM {ins_q}; strap foam pads: {straps} straps, BOM names {foam_n}")
nuts_doc = re.search(r"(\d+) in all", ASSEMBLY)
add("docs", bool(nuts_doc) and int(nuts_doc.group(1)) == nuts and f"{screws} screws" in " ".join(r["notes"] for r in BOM),
    "Assembly guide and BOM state the counts the model has",
    f"assembly.md: {nuts_doc.group(1) if nuts_doc else '?'} nuts (model {nuts}); bom.csv: '{screws} screws' {'found' if f'{screws} screws' in ' '.join(r['notes'] for r in BOM) else 'not found'}")

# ---------------------------------------------------------------- 2. the shells close (and open) straight
interlock = []
parts = [c for c in L["comps"] if c.get("role") not in ("keep-out",) and not c["name"].startswith("LED")]
for a in parts:
    for b in parts:
        if a is b or shell(a) != "top" or shell(b) != "bottom":
            continue
        (ax, ay, az), (sx, sy, sz) = a["at"], a["size"]
        (bx, by, bz), (tx, ty, tz) = b["at"], b["size"]
        if ax < bx + tx and bx < ax + sx and ay < by + ty and by < ay + sy and az < bz + tz - 0.05:
            interlock.append(f"{a['name']} (top) over {b['name']} (bottom): {bz + tz - az:.1f} mm")
add("fit", not interlock, "The top shell lowers straight onto the bottom",
    "no top-shell part sits under a bottom-shell part anywhere their plans overlap, so the shells close and open straight" if not interlock
    else f"{len(interlock)} interlocks: " + "; ".join(interlock[:6]))

# ---------------------------------------------------------------- 3. a driver reaches every screw at its step
# assembly order (assembly.md §5 and §6): a screw is driven when its part goes in; parts already in can block it
# ADR-0020: bottom shell: floor boards, cell, deck, deck boards, then the Radxa over them; top (lid): eye, mics, tag, plug
STEP = {"lid connector": 4, "USB-C": 1, "5 V boost": 1, "ideal diode": 1, "battery": 2, "battery deck": 3, "deck": 3, "RTC": 3, "vibration": 3, "power switch": 4,
        "mic switch": 4, "eye": 1, "mic-live": 1, "mic head": 2, "mic tail": 2, "NFC icon": 3, "NFC tag": 3, "bq24074": 4, "charger DC": 4,
        "ATECC": 3, "power switch Pololu": 1, "SoC": 6, "radxa": 5, "post ": 0}


def step_of(name):
    owner = name.split(" of ", 1)[1] if " of " in name else name
    best = None
    for k, v in STEP.items():
        if owner.startswith(k) and (best is None or len(k) > best[0]):
            best = (len(k), v)
    return best[1] if best else 9


BIT_R = 2.5          # a TX8 bit is ≈ 3 mm across; 2.5 mm radius leaves room to see and to tilt a little
blocked, later = [], []
for c in L["comps"]:
    if c.get("role") != "fastener" or not c["name"].startswith("screw head"):
        continue
    owner = c["name"].split(" of ", 1)[1]
    sh = shell(c)
    cx, cy = c["at"][0] + c["size"][0] / 2, c["at"][1] + c["size"][1] / 2
    # the open side: up for the bottom shell (worked base-down), down for the top shell (worked dome-down)
    z0, z1 = (c["at"][2] + c["size"][2], 99.0) if sh == "bottom" else (-99.0, c["at"][2])
    st = step_of(c["name"])
    for o in parts:
        if o["name"] == owner or o["name"].endswith(f" of {owner}") or shell(o) != sh or o.get("role") == "fastener":
            continue
        (ox, oy, oz), (sx, sy, sz) = o["at"], o["size"]
        dx = max(ox - cx, 0, cx - (ox + sx)); dy = max(oy - cy, 0, cy - (oy + sy))
        if math.hypot(dx, dy) < BIT_R and oz < z1 and oz + sz > z0:
            (blocked if step_of(o["name"]) < st else later).append((c["name"], o["name"]))
add("access", not blocked, "A driver reaches every screw when its part goes in",
    f"{sum(1 for c in L['comps'] if c['name'].startswith('screw head'))} screws, each with a clear Ø{2 * BIT_R:.0f} mm line from the open side at its step"
    + (f"; {len(later)} are covered later (take those parts off first to reach them in a rebuild)" if later else "")
    if not blocked else "blocked: " + "; ".join(f"{a} by {b}" for a, b in blocked[:6]))
rebuild_order = sorted({f"{a.split(' of ', 1)[1]} ← {b}" for a, b in later})

# ---------------------------------------------------------------- 4. every nut slot opens into free space
meshes = {n: trimesh.load(OUT / f"{n}.stl") for n in ("top_shell", "bottom_shell", "battery_deck") if (OUT / f"{n}.stl").exists()}
slot_bad = []
for c in L["comps"]:
    m = c.get("mount")
    if not m:
        continue
    bcx, bcy = c["at"][0] + c["size"][0] / 2, c["at"][1] + c["size"][1] / 2
    strap = COMPS.get(f"strap of {c['name']}")
    for (hx, hy), sup in zip(m["holes"], m["supports"]):
        if m["side"] == "roof":
            face = (strap["at"][2] + strap["size"][2]) if strap else (c["at"][2] + c["size"][2])
            zp, mesh = face + BOSS_CAP + (NUT_M + 0.3) / 2, meshes["top_shell"]
        else:
            face = strap["at"][2] if strap else c["at"][2]
            zp, mesh = face - BOSS_CAP - (NUT_M + 0.3) / 2, meshes["battery_deck" if sup == "deck" else "bottom_shell"]
        rec = next((x for x in mounts if x["board"] == c["name"] and abs(x["at"][0] - hx) < 0.01 and abs(x["at"][1] - hy) < 0.01), None)
        d = np.array(rec["slot"]) if rec and "slot" in rec else np.array([bcx - hx, bcy - hy])   # the slot as printed
        d = d / (np.linalg.norm(d) or 1)
        pts = [(hx + d[0] * r, hy + d[1] * r, zp) for r in np.arange(BOSS_D / 2 + 0.6, BOSS_D / 2 + 6.0, 0.5)]
        inside = mesh.contains(pts)
        if inside.any():
            slot_bad.append(f"{c['name']} at ({hx:.0f}, {hy:.0f})")
add("access", not slot_bad, "Every nut slot opens into free space",
    f"{len(mounts)} slots, each clear for 5 mm beyond its boss, so a nut slides in with tweezers" if not slot_bad else "blocked by printed material: " + "; ".join(slot_bad[:6]))

# ---------------------------------------------------------------- 5. no board component where a boss, nut or head lands
VENDOR = {"ATECC608 secure element": ("atecc608", [(2.54, 2.54), (2.54, 15.24), (22.86, 2.54), (22.86, 15.24)]),
          "bq24074 charger (Adafruit 4755)": ("charger_bq24074", [(2.54, 2.54), (2.55, 30.49), (35.56, 2.54), (35.56, 30.48)]),
          "mic head (SPH0645)": ("mic_sph0645", [(2.54, 10.16), (13.97, 10.16)]),
          "mic tail (SPH0645)": ("mic_sph0645", [(2.54, 10.16), (13.97, 10.16)]),
          "NFC tag ST25DV64KC": ("nfc_st25dv", [(2.54, 2.54), (22.86, 2.54)]),
          "USB-C breakout (charge port)": ("usbc_4090", [(2.54, 11.44), (17.78, 11.43)]),
          "radxa pcb": ("radxa_zero3e", [(3.5, 3.5), (3.5, 26.5), (61.5, 3.5), (61.5, 26.5)])}
clash, checked, closest = [], 0, []
for name, (key, holes) in VENDOR.items():
    c = COMPS.get(name)
    if not c or not c.get("mount") or c["mount"]["kind"] != "nut":
        continue
    pcb = trimesh.load(HERE / "vendor" / "models" / key / f"{key}_pcb.stl")
    prt = trimesh.load(HERE / "vendor" / "models" / key / f"{key}_parts.stl")
    lo, hi = pcb.bounds
    v = prt.vertices
    for hx, hy in holes:
        px, py = lo[0] + hx, lo[1] + hy
        r_ = np.hypot(v[:, 0] - px, v[:, 1] - py)
        # a component is anything standing more than 0.15 mm off the board: thinner is copper and solder mask (the
        # plated ring the head is meant to bear on). The head's real radius, ISO 14580 dk max 4.5 mm, no margin added.
        top = v[(r_ < SCREW_HEAD_D / 2) & (v[:, 2] > hi[2] + 0.15)]
        bot = v[(r_ < c["mount"].get("boss_r", BOSS_D / 2)) & (v[:, 2] < lo[2] - 0.15)]
        tv = v[v[:, 2] > hi[2] + 0.15]
        closest.append(float(np.hypot(tv[:, 0] - px, tv[:, 1] - py).min()) - SCREW_HEAD_D / 2 if len(tv) else 99.0)
        checked += 1
        if len(top):
            clash.append(f"{name}: a part {top[:, 2].max() - hi[2]:.1f} mm tall under the screw head at ({hx}, {hy})")
        if len(bot):
            clash.append(f"{name}: a part on the underside where the boss lands at ({hx}, {hy})")
add("fit", not clash, "No board component where a boss or screw head lands",
    f"{checked} holes on {len([n for n in VENDOR if n in COMPS])} boards with vendor models: the Ø{SCREW_HEAD_D} mm head and the Ø{BOSS_D:.1f} mm boss find bare board; "
    f"closest component {min(closest):.2f} mm outside a head's rim "
    "(the clock's PCB file has no 3D model: not checked)" if not clash else "; ".join(clash[:6]))

# ---------------------------------------------------------------- 6. every cable reaches, with the shells open
RUNS = {r["run"]: r for r in W["runs"]}
CABLES = {"STEMMA QT: Radxa → secure element": 150, "STEMMA QT: secure element → clock": 200}   # bom.csv J4-6: 4209, 4401
too_short = []
for run, have in CABLES.items():   # a bought cable has a fixed length: its route plus 10 % and 10 mm for the bends at the plugs
    r = RUNS.get(run)
    need_mm = r["length_mm"] * 1.1 + 10 if r else 9e9
    if need_mm > have:
        too_short.append(f"{run}: needs {need_mm:.0f} mm, the BOM's cable is {have} mm")
add("wiring", not too_short, "Every bought cable is long enough",
    "the two STEMMA QT cables reach their routed path, pad to pad, × 1.1 + 10 mm: " + ", ".join(f"{k.split(': ')[1]} {RUNS[k]['length_mm'] * 1.1 + 10:.0f} of {v} mm" for k, v in CABLES.items())
    if not too_short else "; ".join(too_short))
seam_parts = [r["run"] for r in W["runs"] if r["crosses_seam"]]
# ADR-0020: the lid unplugs, so nothing may cross the seam except through the lid connector
LIDC = "lid connector (12-way)"
if LIDC in COMPS:
    stray = [r["run"] for r in W["runs"] if r["crosses_seam"] and LIDC not in r["ends"]]
    n_c = sum(len(r["conductors"]) for r in W["runs"] if r["crosses_seam"])
    add("wiring", not stray, "Only the lid connector crosses the seam",
        f"{len(seam_parts)} runs ({n_c} wires) cross the seam, every one ending at the 12-way lid connector: unplug it and the lid comes off" if not stray
        else "loose across the seam: " + ", ".join(stray))

# ADR-0020: for bring-up the lid lies open beside the chassis, still plugged in, hinged on its +Y edge; every run
# across the seam must reach in that pose (routed: wire_routes.py, ADR-0021), clear of every part, and every wire to or
# from the connector is one of its pigtails: 450 mm on both halves (ADR-0021)
if LIDC in COMPS:
    PIGTAIL = {"c": 450, "l": 450}
    seam_w = [r for r in W["runs"] if r["crosses_seam"]]
    S_ = W["summary"]
    short_c = S_.get("open_unrouted", ["(not routed open)"]) + S_.get("open_short", []) + S_.get("open_clash", [])
    tails = [w for w in W["wires"] if w["gauge"] == "g125"]
    half = lambda w: next(e["pad"][0] for e in w["pads"] if e["part"] == LIDC)
    long_c = sorted({f"{w['run']} ({w['cut_mm']} mm of {PIGTAIL[half(w)]})" for w in tails if w["cut_mm"] > PIGTAIL[half(w)]})
    longest = {h: max((w["cut_mm"] for w in tails if half(w) == h), default=0) for h in PIGTAIL}
    add("wiring", bool(seam_w) and not short_c and not long_c, "The lid opens like a book, still plugged in",
        f"all {len(seam_w)} runs across the seam are routed with the lid turned 180° about its +Y edge: each reaches, at its closed length, "
        f"and passes no part; all {len(tails)} wires to the connector are its own pigtails (longest {longest['c']} of 450 mm on the chassis half, "
        f"{longest['l']} of 450 mm on the lid half)" if bool(seam_w) and not short_c and not long_c
        else "; ".join(["open lid: " + "; ".join(short_c)] * bool(short_c) + ["longer than its pigtail: " + ", ".join(long_c)] * bool(long_c)))

# ---------------------------------------------------------------- 6b. pads (board_pads.py): every wire on a real pad, reachable
import board_pads as BP   # noqa: E402

BOARDS = W["boards"]
# (1) each vendor board's frame puts its model's own mounting holes on the bosses the packer placed
off = []
for n, key in BP.VENDOR.items():
    c = COMPS.get(n)
    if not c or not c.get("mount") or c["mount"]["kind"] != "nut":
        continue
    m = trimesh.load(HERE / "vendor" / "models" / key / f"{key}_pcb.stl")
    lo, hi = m.bounds
    sec = m.section(plane_origin=[0, 0, (lo[2] + hi[2]) / 2], plane_normal=[0, 0, 1])
    holes = []
    for loop in sec.discrete:
        x, y = loop[:, 0], loop[:, 1]
        area = 0.5 * abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
        if max(np.ptp(x), np.ptp(y)) < 6 and 2 * math.sqrt(area / math.pi) > 1.9:
            holes.append(BP.apply(BP.frame(c), (x.mean(), y.mean(), 0))[:2])
    for hx, hy in c["mount"]["holes"]:
        d = min((math.hypot(hx - a, hy - b) for a, b in holes), default=99)
        if d > 0.5:
            off.append(f"{n}: boss at ({hx}, {hy}) is {d:.1f} mm from the model's nearest hole")
n_vb = sum(1 for n in BP.VENDOR if n in COMPS and COMPS[n].get("mount", {}).get("kind") == "nut")
add("wiring", not off, "Boards drawn on their own holes",
    f"the frame each of the {n_vb} screwed boards with a vendor model is drawn in (and its pads placed in) puts the model's mounting holes on the "
    "packer's bosses, within 0.5 mm" if not off else "; ".join(off))
# (2) every pad in use lies on its part, and the wire leaves it into free space: nothing (a strap, a boss, another
# board) sits on the joint or in the first few mm of the wire's way off it
SAME = {"radxa pcb": "radxa "}   # the Radxa's own envelope boxes (its top parts, ports, underside): the same board, not in the way
inside = lambda p, c, m=0.6: all(c["at"][k] - m <= p[k] <= c["at"][k] + c["size"][k] + m for k in range(3))
blocked, astray, n_used = [], [], 0
for n, b in BOARDS.items():
    c = COMPS.get(n)
    for pd in b["pads"]:
        if not pd["used"]:
            continue
        n_used += 1
        if c and not inside(pd["at"], c, 1.5 if pd["kind"] in ("plug", "lead") else BP.LEG.get(n, 0) + 0.6):
            astray.append(f"{b['title']} {pd['label']}")
        lift = BP.LIFT.get(pd["kind"], 2.0)
        for t in (0.6, lift * 0.5, lift):
            q = [pd["at"][k] + pd["dir"][k] * t for k in range(3)]
            hit = next((o["name"] for o in L["comps"] if o["name"] != n and not o["name"].startswith(SAME.get(n, "\0"))
                        and o.get("role") != "keep-out" and inside(q, o, 0.0)
                        and (not o["name"].endswith(f" of {n}") or o["name"].startswith("strap of"))), None)
            if hit:
                blocked.append(f"{b['title']} {pd['label']} (under {hit})")
                break
add("wiring", not astray, "Every wire ends on a pad of its part",
    f"all {len(W['wires'])} wires run pad to pad: {n_used} pads in use on {len(BOARDS)} parts, each on its own board's outline" if not astray else "pad off its part: " + ", ".join(astray))
add("wiring", not blocked, "Every soldered pad can be reached",
    f"the first {min(BP.LIFT.values()):.0f}–{max(BP.LIFT.values()):.1f} mm off each of the {n_used} pads in use is free: no strap, other board or part on a joint" if not blocked
    else "blocked: " + "; ".join(sorted(set(blocked))[:8]))
# (2b) every wire, every leg and every joint lies inside the case: in the cavity (not in a wall) and clear of every
# other part. A wire is tested pad to pad, closed and with the lid open, its own two parts' boxes aside (it starts
# inside their envelopes) but not their bosses or straps; a leg (a switch's pins) from the part to its tip
PB = SPEC.pebble()


def in_cavity(q, inset=SPEC.wall - 0.05):
    x, y, z = q
    w, h = PB.inset_of(PB.half_width, x, inset), PB.inset_of(PB.top if z >= 0 else PB.bot, x, inset)
    n = PB.n_top if z >= 0 else PB.n_bot
    return abs(x) <= PB.L / 2 - inset * 2.6 and w > 0 and h > 0 and (abs(y) / w) ** n + (abs(z) / h) ** n <= 1


def rs(P, step=0.5):
    out = [np.array(P[0], float)]
    for a_, b_ in zip(P, P[1:]):
        a_, b_ = np.array(a_, float), np.array(b_, float)
        k = max(1, int(math.ceil(np.linalg.norm(b_ - a_) / step)))
        out += [a_ + (b_ - a_) * (j / k) for j in range(1, k + 1)]
    return out


in_box = lambda q, c: all(c["at"][k] < q[k] < c["at"][k] + c["size"][k] for k in range(3))
walls, through = [], []
for w in W["wires"]:
    if not w.get("routed"):
        continue
    own = set(w["ends"]) | ({n for n in COMPS if n.startswith("radxa ")} if "radxa pcb" in w["ends"] else set())
    for q in rs(w["closed_points"]):
        if not in_cavity(q):
            walls.append(f"{w['run']}: {w['net']} at ({q[0]:.0f}, {q[1]:.0f}, {q[2]:.0f})")
            break
    for q in rs(w["closed_points"])[4:-4]:
        hit = next((c["name"] for c in L["comps"] if c["name"] not in own and c.get("role") != "keep-out" and in_box(q, c)), None)
        if hit:
            through.append(f"{w['run']}: {w['net']} through {hit}")
            break
for n, leg in BP.LEG.items():
    for pd in BOARDS.get(n, {}).get("pads", []):
        base = [pd["at"][k] - pd["dir"][k] * leg for k in range(3)]
        if not all(in_cavity(q) for q in rs([base, pd["at"]], 0.2)):
            walls.append(f"{BOARDS[n]['title']} {pd['label']}: its {leg} mm leg")
add("wiring", not walls and not through, "Every wire, leg and joint fits inside the case",
    f"all {len(W['wires'])} wires, pad to pad with their loops, lie in the cavity and pass through no other part; every switch leg fits"
    if not walls and not through else "; ".join((["in a wall: " + "; ".join(walls[:8])] if walls else []) + (["through a part: " + "; ".join(through[:8])] if through else [])))

# (3) the wires in one through-hole pad fit its hole: their bare cores take at most half the hole's area
CORE = {26: 0.40, 30: 0.25, "g125": 0.40, "qt": 0.64}   # 26 AWG 7/34 ≈ 0.40 mm, 30 AWG 7/38 ≈ 0.25 mm; a STEMMA QT male pin, 0.64 mm square
DRILL = 1.0                                               # the header holes on every board here are 0.98–1.02 mm (vendor files)
crowded, worst = [], (0, "")
for n, b in BOARDS.items():
    for pd in b["pads"]:
        if pd["kind"] != "th" or not pd["used"]:
            continue
        ws = [w for w in W["wires"] for e in w["pads"] if e["part"] == n and e["pad"] == pd["name"]]
        fill = sum(CORE[w["gauge"]] ** 2 for w in ws) / DRILL ** 2
        worst = max(worst, (fill, f"{b['title']} {pd['label']} ({len(ws)} wires)"))
        if fill > 0.5:
            crowded.append(f"{b['title']} {pd['label']}: {len(ws)} wires, {fill * 100:.0f} % of the hole")
add("wiring", not crowded, "Wires fit their pad holes",
    f"no plated hole holds more bare core than half its area (Ø{DRILL} mm holes); fullest {worst[1]}, {worst[0] * 100:.0f} %" if not crowded else "; ".join(crowded))

# ---------------------------------------------------------------- 7. every print fits the bed, and the supports are named
bed = re.search(r"at least (\d+) × (\d+) mm", PSPEC["defaults"]["printer"])
bx_, by_ = (int(bed.group(1)), int(bed.group(2))) if bed else (0, 0)
big = [f"{n} {v['size'][0]:.0f} × {v['size'][1]:.0f}" for n, v in PRINT.items() if sorted(v["size"][:2], reverse=True)[0] > bx_ or sorted(v["size"][:2], reverse=True)[1] > by_]
add("print", not big, "Every print fits the stated bed", f"bed {bx_} × {by_} mm (print_spec.json); largest {max(PRINT.items(), key=lambda kv: kv[1]['size'][0])[0]} "
    f"{max(v['size'][0] for v in PRINT.values()):.0f} mm" if not big else "too big: " + ", ".join(big))
roof_bosses = sum(1 for x in mounts if x["side"] == "roof")
sup_note = PSPEC["parts"]["top_shell"]["supports"]
add("print", roof_bosses == 0 or "boss" in sup_note, "The top shell's print notes name its hanging bosses",
    f"{roof_bosses} bosses hang from the roof; they print from their free ends, in mid-air, so they need supports like the board posts"
    + ("" if "boss" in sup_note else ": print_spec.json doesn't mention them"))

# ---------------------------------------------------------------- 8. every board is wired, every wired board is in the model
src = (HERE / "wire_routes.py").read_text()
unwired = [n for n in BUY if n in COMPS and n not in ("SoC thermal pad (to the roof)", "mic-live dot (sequin)", "eye switch (tactile, in cradle)")
           and f'"{n}"' not in src and n not in src]
add("wiring", not unwired, "Every board in the model is wired", "each board is an end of at least one routed wire in wire_routes.py" if not unwired else "no wires to: " + ", ".join(unwired))

json.dump({"results": results, "rebuild_order": rebuild_order, "seam_wires": seam_parts}, open(OUT / "buildability.json", "w"), indent=1)
for r in results:
    print(("PASS" if r["ok"] else "FAIL"), f"[{r['area']}] {r['what']} — {r['detail']}")
print(f"{sum(r['ok'] for r in results)}/{len(results)} buildability checks pass")
if rebuild_order:
    print("rebuild: to take off  ← first remove:", "; ".join(rebuild_order))
