# SPDX-License-Identifier: Apache-2.0
"""Route every wire of wiring.md through the free space of the closed pebble, and check that the case fits them.

    python v01/cad/wire_routes.py          → out_pebble/wires.json (+ printed summary)

Free space is the cavity (less the wall, and less the light strip's zone near the wall) minus every part in
layout.json. Each wire is an A* path on a 1 mm voxel grid from a pad on one part to a pad on another, kept away from
parts where it can be (a cost on closeness). Wires may share voxels: that is a bundle. The checks:
  - every wire routes;
  - every bundle fits: the free space around it is at least the bundle's radius (diameter from the wires' areas);
  - a wire that crosses the seam is no longer than the 100 mm lead assembly.md cuts, so the shells open side by side.
Pads are approximate (a part's face toward the other end, or the Radxa header pin), so lengths are ±10 mm.
"""
from __future__ import annotations

import heapq
import json
import math
from pathlib import Path

import numpy as np
from scipy import ndimage

from pebble_layout import Spec, shell_of

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()
COMPS = {c["name"]: c for c in L["comps"]}
STEP = 1.0
D = {26: 1.6, 30: 1.0, "qt": 1.0, "g125": 1.0}         # outside diameter: silicone 26 AWG ≈ 1.6 mm, 30 AWG ≈ 1.0 mm; one core of a STEMMA QT cable ≈ 1.0 mm (four make the ≈ 2.2 mm cable); a Harwin pigtail, 26 AWG PTFE, ≈ 1.0 mm (thin wall)
GAUGE = {26: "26 AWG silicone", 30: "30 AWG silicone", "qt": "STEMMA QT cable", "g125": "26 AWG PTFE pigtail (white)"}
COL = {"power": "#d0342c", "ground": "#222222", "data": "#e8b600", "i2c": "#2f6fd6", "mic": "#7a3fb0", "control": "#2e9d57"}

# ---------------------------------------------------------------- free space
xs = np.arange(-SPEC.L / 2, SPEC.L / 2 + STEP, STEP)
ys = np.arange(-SPEC.W / 2, SPEC.W / 2 + STEP, STEP)
zs = np.arange(-pb.H_bot, pb.H_top + STEP, STEP)
X, Y, Z = np.meshgrid(xs, ys, zs, indexing="ij")


def cavity(inset):
    w = np.array([pb.inset_of(pb.half_width, x, inset) for x in xs])[:, None, None]
    ht = np.array([pb.inset_of(pb.top, x, inset) for x in xs])[:, None, None]
    hb = np.array([pb.inset_of(pb.bot, x, inset) for x in xs])[:, None, None]
    Lx = pb.L / 2 - inset * 2.6
    ok = (np.abs(X) <= Lx) & (w > 0) & (ht > 0) & (hb > 0)
    n = np.where(Z >= 0, pb.n_top, pb.n_bot)
    h = np.where(Z >= 0, ht, hb)
    with np.errstate(divide="ignore", invalid="ignore"):
        r = (np.abs(Y) / np.where(w > 0, w, 1)) ** n + (np.abs(Z) / np.where(h > 0, h, 1)) ** n
    return ok & (r <= 1.0)


free = cavity(SPEC.wall + 0.4)
cav_ok = cavity(SPEC.wall).copy()                     # inside the walls at all (a wire's pad tails are tested against this)
band = L["info"].get("light_band")
if band:                                    # the strip and its land sit in the outer 3 mm at strip height: no wires there
    zb = (Z >= band[0] - 0.5) & (Z <= band[1] + 0.5)
    free &= ~zb | cavity(SPEC.wall + SPEC.band_gap + 1.2)
for c in L["comps"]:
    (x0, y0, z0), (sx, sy, sz) = c["at"], c["size"]
    m = 0.5   # a wire between two free voxel centres stays out of the part
    free &= ~((X >= x0 - m) & (X <= x0 + sx + m) & (Y >= y0 - m) & (Y <= y0 + sy + m) & (Z >= z0 - m) & (Z <= z0 + sz + m))
clear = ndimage.distance_transform_edt(free) * STEP          # mm from each free voxel to the nearest part or wall

idx = lambda p: tuple(int(round((v - o) / STEP)) for v, o in zip(p, (xs[0], ys[0], zs[0])))
pos = lambda i: (xs[i[0]], ys[i[1]], zs[i[2]])


def nearest_free(p):
    i = idx(p)
    best, bd = None, 1e9
    r = 6
    for a in range(max(0, i[0] - r), min(len(xs), i[0] + r + 1)):
        for b in range(max(0, i[1] - r), min(len(ys), i[1] + r + 1)):
            for c in range(max(0, i[2] - r), min(len(zs), i[2] + r + 1)):
                if free[a, b, c]:
                    d = (a - i[0]) ** 2 + (b - i[1]) ** 2 + (c - i[2]) ** 2
                    if d < bd:
                        best, bd = (a, b, c), d
    return best


# ---------------------------------------------------------------- pads and runs (board_pads.py, wiring.md)
# A run is what a builder lays as one: a few conductors from one place to another (a pair to a board, the I²S bus, a
# STEMMA QT cable). The router finds the run's path once, as a bundle, between its two fan points; each conductor
# then leaves the bundle for its own pad. Every end is a named pad on its board (board_pads.PADS), placed through
# the board's frame, so lengths run pad to pad.
from board_pads import pad_end, boards_json, LIFT   # noqa: E402

PE = lambda part, pad: pad_end(L, part, pad)
CH, BO, PO, ID = "bq24074 charger (Adafruit 4755)", "5 V boost U3V40F5", "power switch Pololu 2810", "ideal diode LM66200 (Adafruit 5830)"
USB, BAT, ICON, EYE, ERM = "USB-C breakout (charge port)", "battery", "NFC icon LED (NeoPixel Mini)", "eye switch (tactile, in cradle)", "vibration disc (ERM)"
MH, MT, SW1, SW2, SEQ = "mic head (SPH0645)", "mic tail (SPH0645)", "power switch", "mic switch", "mic-live dot (sequin)"
SE, RTC, TAG, RX, LIDC = "ATECC608 secure element", "RTC PCF8523 + CR1220 (STEMMA QT)", "NFC tag ST25DV64KC", "radxa pcb", "lid connector (12-way)"
z_split = L["z_split"]
# plain names for the parts at a wire's ends (the site's hover label: which components a wire joins)
NICE = {RX: "Radxa (compute)", BAT: "battery", CH: "charger", USB: "USB-C port", BO: "5 V boost", PO: "power switch module",
        ID: "ideal diode", SW1: "power slide switch", SW2: "mic slide switch", MH: "head mic", MT: "tail mic", EYE: "eye button",
        SEQ: "eye light", ICON: "tap-icon LED", ERM: "vibration motor", SE: "secure element", RTC: "clock", TAG: "NFC tag",
        LIDC: "lid connector", "strip": "light-ring strip"}
nice = lambda e: NICE.get(e.part, e.part)
# the lid connector's 12 ways (ADR-0020, ADR-0021): which line takes which pin. "c n" is the chassis end, "l n" the lid end.
from board_pads import LID_LINES   # noqa: E402
LID_PIN = {v: k for k, v in LID_LINES.items()}
cn = lambda line: PE(LIDC, f"c{LID_PIN[line]}")
ln = lambda line: PE(LIDC, f"l{LID_PIN[line]}")
R = lambda n: PE(RX, f"pin {n}")
G26, G30, QT, PT = 26, 30, "qt", "g125"   # PT: a Harwin pigtail, 26 AWG PTFE (ADR-0020)


def mouth_clear(e):
    """A plug fits: the 4.5 mm in front of the socket's mouth is clear of every other part, boss head and strap."""
    for t in (1.0, 2.5, 4.5):
        q = [e.at[k] + e.dir[k] * t for k in range(3)]
        if any(c["name"] != e.part and c.get("role") != "keep-out" and all(c["at"][k] <= q[k] <= c["at"][k] + c["size"][k] for k in range(3)) for c in L["comps"]):
            return False
    return True


def nearer(part, pads, target):
    """Of a board's interchangeable pads (two STEMMA QT sockets), the one a plug fits, nearer the other end."""
    t = PE(*target) if isinstance(target, tuple) else target
    return min((PE(part, p_) for p_ in pads), key=lambda e: (not mouth_clear(e), math.dist(e.at, t.at)))


SE_QT = ("QT left", "QT right")
QT_SE_IN = nearer(SE, SE_QT, R(3))                                   # the cable from the Radxa takes the nearer socket
QT_SE_OUT = PE(SE, next(p_ for p_ in SE_QT if p_ != QT_SE_IN.pad))
QT_RTC = nearer(RTC, SE_QT, QT_SE_OUT)
# (label, kind, gauge, from, to, inline part or None: (what, the end it sits by, sleeve mm))
RUNS = [
    # ---- power, in the chassis (wiring.md §1)
    ("USB-C port → charger", [
        ("VBUS", "power", G26, PE(USB, "VBUS"), PE(CH, "VBUS"), None),
        ("GND", "ground", G26, PE(USB, "GND"), PE(CH, "GND"), None)]),
    ("Battery lead → charger", [
        ("cell +", "power", G26, PE(BAT, "+"), PE(CH, "BATT +"), ("F2 MF-R300 fuse", "a", 9)),
        ("cell −", "ground", G26, PE(BAT, "−"), PE(CH, "BATT −"), None)]),
    ("Thermistor → charger", [
        ("RT1", "control", G30, PE(BAT, "RT1 a"), PE(CH, "THERM"), None),
        ("RT1 GND", "ground", G30, PE(BAT, "RT1 b"), PE(CH, "GND 3"), None)]),
    ("Charger → power switch module", [            # the header's OUT and GND: the Load Out socket's nets, without its detour
        ("OUT", "power", G26, PE(CH, "OUT"), PE(PO, "VIN"), None),
        ("GND", "ground", G26, PE(CH, "GND 2"), PE(PO, "GND"), None)]),
    ("Power switch module → boost", [
        ("SW-RAIL", "power", G26, PE(PO, "VOUT"), PE(BO, "VIN"), None),
        ("GND", "ground", G26, PE(PO, "GND 3"), PE(BO, "GND"), None)]),
    ("Power slide → power switch module", [
        ("control", "control", G30, PE(SW1, "COM"), PE(PO, "SW"), None),
        ("GND", "ground", G30, PE(SW1, "1"), PE(PO, "GND 5"), None)]),
    ("Boost → ideal diode", [
        ("5 V", "power", G26, PE(BO, "VOUT"), PE(ID, "VIN 1"), None),
        ("GND", "ground", G26, PE(BO, "GND"), PE(ID, "GND"), None)]),
    ("Ideal diode → Radxa", [
        ("5 V", "power", G26, PE(ID, "Vout"), R(2), None),
        ("GND", "ground", G26, PE(ID, "GND"), R(6), None)]),
    ("Boost → ring strip", [
        ("5 V", "power", G26, PE(BO, "VOUT"), PE("strip", "+5V"), ("F1 MF-R110 fuse", "b", 9)),
        ("GND", "ground", G26, PE(BO, "GND 2"), PE("strip", "GND"), None)]),
    # ---- haptic (wiring.md §5): the PN2222A and its diode ride in heat-shrink on the motor's leads
    ("Power switch module → motor", [
        ("SW-RAIL", "power", G30, PE(PO, "VOUT 2"), PE(ERM, "+"), None)]),
    ("Radxa → motor", [
        ("pin 18", "control", G30, R(18), PE(ERM, "Q1 B"), ("R 470 Ω", "a", 6)),
        ("GND", "ground", G30, R(20), PE(ERM, "Q1 E"), None)]),
    # ---- I²C (wiring.md §4): plug-in cables
    ("STEMMA QT: Radxa → secure element", [
        ("3V3", "power", QT, R(1), QT_SE_IN, None), ("GND", "ground", QT, R(9), QT_SE_IN, None),
        ("SDA", "i2c", QT, R(3), QT_SE_IN, None), ("SCL", "i2c", QT, R(5), QT_SE_IN, None)]),
    ("STEMMA QT: secure element → clock", [
        ("3V3", "power", QT, QT_SE_OUT, QT_RTC, None), ("GND", "ground", QT, QT_SE_OUT, QT_RTC, None),
        ("SDA", "i2c", QT, QT_SE_OUT, QT_RTC, None), ("SCL", "i2c", QT, QT_SE_OUT, QT_RTC, None)]),
    # ---- to the lid: each line once, through the connector (ADR-0020)
    ("Radxa → mic slide", [
        ("3V3", "mic", G30, R(17), PE(SW2, "COM"), None)]),
    ("Mic slide → the lid", [
        ("mic rail", "mic", PT, PE(SW2, "1"), cn("mic rail"), None)]),
    ("Radxa I²S → the lid", [
        ("BCLK", "mic", PT, R(12), cn("BCLK"), None), ("LRCL", "mic", PT, R(35), cn("LRCL"), None),
        ("DOUT", "mic", PT, R(38), cn("DOUT"), ("R 100 kΩ to GND", "a", 6)), ("GND", "ground", PT, R(39), cn("GND"), None)]),
    ("Radxa → the lid: eye button", [
        ("pin 16", "control", PT, R(16), cn("eye"), None)]),
    ("Radxa → the lid: icon LED data", [
        ("pin 19", "data", PT, R(19), cn("icon DIN"), ("R 470 Ω", "a", 6))]),
    ("Ring strip → the lid: icon LED power and data back", [
        ("5 V", "power", PT, PE("strip", "+5V"), cn("icon 5 V"), ("D4 1N4148", "a", 6)),
        ("DOUT", "data", PT, PE("strip", "DIN"), cn("icon DOUT"), None)]),
    ("Clock → the lid: NFC tag I²C", [
        ("3V3", "power", PT, PE(RTC, "VIN"), cn("3V3"), None),
        ("SDA", "i2c", PT, PE(RTC, "SDA"), cn("SDA"), None), ("SCL", "i2c", PT, PE(RTC, "SCL"), cn("SCL"), None)]),
    # ---- in the lid: from the connector's lid end; shared lines daisy-chain pad to pad
    ("Lid connector → tail mic", [
        ("mic rail", "mic", PT, ln("mic rail"), PE(MT, "3V"), None),
        ("BCLK", "mic", PT, ln("BCLK"), PE(MT, "BCLK"), None), ("LRCL", "mic", PT, ln("LRCL"), PE(MT, "LRCL"), None),
        ("DOUT", "mic", PT, ln("DOUT"), PE(MT, "DOUT"), None)]),
    ("Tail mic SEL → 3V (right channel)", [
        ("SEL", "mic", G30, PE(MT, "SEL"), PE(MT, "3V"), None)]),
    ("Tail mic → head mic", [
        ("mic rail", "mic", G30, PE(MT, "3V"), PE(MH, "3V"), None), ("GND", "ground", G30, PE(MT, "GND"), PE(MH, "GND"), None),
        ("BCLK", "mic", G30, PE(MT, "BCLK"), PE(MH, "BCLK"), None), ("LRCL", "mic", G30, PE(MT, "LRCL"), PE(MH, "LRCL"), None),
        ("DOUT", "mic", G30, PE(MT, "DOUT"), PE(MH, "DOUT"), None)]),
    ("Head mic SEL → GND (left channel)", [
        ("SEL", "ground", G30, PE(MH, "SEL"), PE(MH, "GND"), None)]),
    ("Head mic → eye light", [
        ("mic rail", "mic", G30, PE(MH, "3V"), PE(SEQ, "+"), None), ("GND", "ground", G30, PE(MH, "GND"), PE(SEQ, "−"), None)]),
    ("Lid connector → eye button", [
        ("pin 16", "control", PT, ln("eye"), PE(EYE, "1"), None)]),
    ("Eye light − → eye button", [
        ("GND", "ground", G30, PE(SEQ, "−"), PE(EYE, "3"), None)]),
    ("Lid connector → tap-icon LED", [
        ("5 V", "power", PT, ln("icon 5 V"), PE(ICON, "+5V"), None),
        ("DIN", "data", PT, ln("icon DIN"), PE(ICON, "DIN"), None), ("DOUT", "data", PT, ln("icon DOUT"), PE(ICON, "DOUT"), None)]),
    ("Lid connector → NFC tag", [
        ("3V3", "power", PT, ln("3V3"), PE(TAG, "3V3"), None), ("GND", "ground", PT, ln("GND"), PE(TAG, "GND"), None),
        ("SDA", "i2c", PT, ln("SDA"), PE(TAG, "SDA"), None), ("SCL", "i2c", PT, ln("SCL"), PE(TAG, "SCL"), None)]),
    ("NFC tag → tap-icon LED: ground", [
        ("GND", "ground", G30, PE(TAG, "GND"), PE(ICON, "GND"), None)]),
    ("Tap-icon LED → tail mic: ground", [
        ("GND", "ground", G30, PE(ICON, "GND out"), PE(MT, "GND"), None)]),
]

def astar(a, b, need=0.0):
    """need: the clearance (mm) a voxel must have, so a bundle of that radius stays in free space; waived within 4 voxels
    of either end, where a run leaves its pads."""
    nb = [(dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1) if (dx, dy, dz) != (0, 0, 0)]
    g = {a: 0.0}
    came = {}
    h = lambda p: math.dist(p, b)
    q = [(h(a), a)]
    shape = free.shape
    while q:
        _, cur = heapq.heappop(q)
        if cur == b:
            path = [cur]
            while cur in came:
                cur = came[cur]
                path.append(cur)
            return path[::-1]
        for d in nb:
            n = (cur[0] + d[0], cur[1] + d[1], cur[2] + d[2])
            if not (0 <= n[0] < shape[0] and 0 <= n[1] < shape[1] and 0 <= n[2] < shape[2]) or not free[n]:
                continue
            if need and clear[n] < need and math.dist(n, a) > 4 and math.dist(n, b) > 4:
                continue
            step = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) * STEP
            cost = g[cur] + step * (1 + 1.5 / (0.5 + clear[n]) + 0.4 * use[n] / max(clear[n], 0.5) ** 2)   # the middle of free space, away from bundles already laid
            if cost < g.get(n, 1e18):
                g[n] = cost
                came[n] = cur
                heapq.heappush(q, (cost + h(n), n))
    return None


def smooth(path):
    """Drop collinear voxels; the site draws a curve through what is left."""
    out = [path[0]]
    for i in range(1, len(path) - 1):
        a, b, c = out[-1], path[i], path[i + 1]
        if (b[0] - a[0], b[1] - a[1], b[2] - a[2]) != (c[0] - b[0], c[1] - b[1], c[2] - b[2]):
            out.append(b)
    out.append(path[-1])
    return out


def fan(ends):
    """A run's fan point at one end: the middle of its pads, each lifted off its board along its own normal."""
    return [float(v) for v in np.mean([np.array(e.at) + np.array(e.dir) * e.lift for e in ends], axis=0)]


def frames_along(pts):
    """A normal and a binormal at each point of a polyline, carried along it without twisting (parallel transport)."""
    P = np.array(pts, float)
    T = np.gradient(P, axis=0) if len(P) > 1 else np.array([[1.0, 0, 0]])
    T = T / np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-9)
    ref = np.array([0, 0, 1.0]) if abs(T[0][2]) < 0.9 else np.array([1.0, 0, 0])
    N = [np.cross(T[0], ref) / (np.linalg.norm(np.cross(T[0], ref)) or 1)]
    for t in T[1:]:
        v = N[-1] - np.dot(N[-1], t) * t
        N.append(v / np.linalg.norm(v) if np.linalg.norm(v) > 1e-9 else N[-1])
    N = np.array(N)
    return N, np.cross(T, N)


plen = lambda pts: sum(math.dist(p, q) for p, q in zip(pts, pts[1:]))
r2 = lambda q: [round(float(v), 2) for v in q]
shell = lambda e: shell_of(COMPS[e.part], z_split, COMPS) if e.part in COMPS else "bottom"   # the strip and the cell's leads: bottom
hinge_y = L["outer"][1] / 2
mirror = lambda q: (q[0], 2 * hinge_y - q[1], 2 * z_split - q[2])   # ADR-0020: the lid turned 180° about its +Y seam edge

runs, use = [], np.zeros(free.shape, dtype=np.float32)
for name, conds in RUNS:
    A, B = [c[3] for c in conds], [c[4] for c in conds]
    fa, fb = fan(A), fan(B)
    ia, ib = nearest_free(fa), nearest_free(fb)
    d2 = sum(D[c[2]] ** 2 for c in conds)
    need = 0.5 * 1.15 * math.sqrt(d2) + 0.2                # the bundle's radius, and a little: its separate wires stay clear
    path = (astar(ia, ib, need) or astar(ia, ib)) if ia and ib else None
    run = {"run": name, "conductors": conds, "routed": bool(path), "d2": d2, "fa": fa, "fb": fb}
    if path:
        for v in path:
            use[v] += d2                                   # bundle area, in d² units
        run["trunk"] = [r2(fa)] + [r2(pos(v)) for v in smooth(path)] + [r2(fb)]
    sh = [shell(A[0]), shell(B[0])]
    run["crosses"] = sh[0] != sh[1]
    run["lid_end"] = (0 if sh[0] == "top" else -1) if run["crosses"] else None
    run["shell"] = "cross" if run["crosses"] else sh[0]
    runs.append(run)


def strands(trunk, conds):
    """Each conductor's own line along the bundle: offset round the trunk, so a run reads as its separate wires."""
    n = len(conds)
    if n == 1:
        return [np.array(trunk, float)]
    dmax = max(D[c[2]] for c in conds)
    rad = max(0.5 * (1.15 * math.sqrt(sum(D[c[2]] ** 2 for c in conds)) - dmax), 0.5 * dmax)
    N, Bn = frames_along(trunk)
    P = np.array(trunk, float)
    s_ = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))])
    taper = np.minimum(1.0, np.minimum(s_, s_[-1] - s_) / 4.0)[:, None]   # the wires come together over the last 4 mm at each end
    return [P + taper * rad * (math.cos(2 * math.pi * i / n) * N + math.sin(2 * math.pi * i / n) * Bn) for i in range(n)]


# ---------------------------------------------------------------- the lid laid open (ADR-0020, ADR-0021)
# For bring-up the lid lies open beside the chassis, turned 180° about the seam's +Y edge, still plugged in. Each run to
# it is routed in that pose through free space: out of the chassis, over the hinge edge, into the turned lid, clear of
# every part (the chassis's where they are, the lid's turned with it) and of both shells' walls. A 2 mm grid.
OS = 2.0
hinge_z = z_split
oxs = np.arange(-SPEC.L / 2 - 6, SPEC.L / 2 + 6 + OS, OS)
oys = np.arange(-SPEC.W / 2 - 6, 2 * hinge_y + SPEC.W / 2 + 6 + OS, OS)
ozs = np.arange(2 * hinge_z - pb.H_top - 4, pb.H_top + 60 + OS, OS)
OX, OY, OZ = np.meshgrid(oxs, oys, ozs, indexing="ij")


def body(Xg, Yg, Zg, inset):
    """Inside the pebble, inset by `inset`, on a grid whose x axis is oxs."""
    w = np.array([pb.inset_of(pb.half_width, x, inset) for x in oxs])[:, None, None]
    ht = np.array([pb.inset_of(pb.top, x, inset) for x in oxs])[:, None, None]
    hb = np.array([pb.inset_of(pb.bot, x, inset) for x in oxs])[:, None, None]
    ok = (np.abs(Xg) <= pb.L / 2 - inset * 2.6) & (w > 0) & (ht > 0) & (hb > 0)
    n = np.where(Zg >= 0, pb.n_top, pb.n_bot)
    h = np.where(Zg >= 0, ht, hb)
    with np.errstate(divide="ignore", invalid="ignore"):
        r = (np.abs(Yg) / np.where(w > 0, w, 1)) ** n + (np.abs(Zg) / np.where(h > 0, h, 1)) ** n
    return ok & (r <= 1.0)


MY, MZ = 2 * hinge_y - OY, 2 * hinge_z - OZ          # where a point of the open lid was, closed
wall_ = SPEC.wall
open_free = ~(body(OX, OY, OZ, 0) & ~body(OX, OY, OZ, wall_) & (OZ < hinge_z))      # the chassis's shell
open_free &= ~(body(OX, MY, MZ, 0) & ~body(OX, MY, MZ, wall_) & (MZ >= hinge_z))    # the lid's, turned over
open_free &= ~((OZ < L["floor_z"]) & (OY < hinge_y))                                 # the table under the chassis
owner_shell = lambda c: shell_of(COMPS[c["name"].split(" of ", 1)[1]], z_split, COMPS) if " of " in c["name"] else shell_of(c, z_split, COMPS)
for c in L["comps"]:
    (x0, y0, z0), (sx, sy, sz) = c["at"], c["size"]
    sh_ = owner_shell(c)
    m = 1.2   # the 2 mm grid: a bundle between two free cell centres stays out of the part
    if sh_ == "top":                                  # turned with the lid: y → 2h − y, z → 2z_s − z
        y0, y1, z0, z1 = 2 * hinge_y - (y0 + sy), 2 * hinge_y - y0, 2 * hinge_z - (z0 + sz), 2 * hinge_z - z0
    else:
        y1, z1 = y0 + sy, z0 + sz
    open_free &= ~((OX >= x0 - m) & (OX <= x0 + sx + m) & (OY >= y0 - m) & (OY <= y1 + m) & (OZ >= z0 - m) & (OZ <= z1 + m))
open_clear = ndimage.distance_transform_edt(open_free) * OS
oidx = lambda p_: tuple(int(round((v - o) / OS)) for v, o in zip(p_, (oxs[0], oys[0], ozs[0])))
opos = lambda i: (float(oxs[i[0]]), float(oys[i[1]]), float(ozs[i[2]]))
z_over = hinge_z + 4.0     # the open runs cross the hinge edge just over the rim


def free_at(p_):
    i = oidx(p_)
    return all(0 <= i[k] < open_free.shape[k] for k in range(3)) and bool(open_free[i])


def onearest(p_, r=5):
    i = oidx(p_)
    best, bd = None, 1e9
    for a in range(max(0, i[0] - r), min(len(oxs), i[0] + r + 1)):
        for b in range(max(0, i[1] - r), min(len(oys), i[1] + r + 1)):
            for c in range(max(0, i[2] - r), min(len(ozs), i[2] + r + 1)):
                if open_free[a, b, c]:
                    d = (a - i[0]) ** 2 + (b - i[1]) ** 2 + (c - i[2]) ** 2
                    if d < bd:
                        best, bd = (a, b, c), d
    return best


def oastar(a, b):
    nb = [(dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1) if (dx, dy, dz) != (0, 0, 0)]
    g, came, q = {a: 0.0}, {}, [(0.0, a)]
    while q:
        _, cur = heapq.heappop(q)
        if cur == b:
            path = [cur]
            while cur in came:
                cur = came[cur]
                path.append(cur)
            return path[::-1]
        for d in nb:
            n = (cur[0] + d[0], cur[1] + d[1], cur[2] + d[2])
            if not all(0 <= n[k] < open_free.shape[k] for k in range(3)) or not open_free[n]:
                continue
            cost = g[cur] + math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) * OS * (1 + 1.0 / (0.5 + open_clear[n]))
            if cost < g.get(n, 1e18):
                g[n], came[n] = cost, cur
                heapq.heappush(q, (cost + math.dist(n, b) * OS, n))
    return None


class Turned:
    """A lid pad as it lies with the lid open."""
    def __init__(self, e):
        self.part, self.pad, self.label, self.kind, self.lift = e.part, e.pad, e.label, e.kind, e.lift
        self.at = list(mirror(e.at))
        self.dir = [e.dir[0], -e.dir[1], -e.dir[2]]


def route_open(run):
    """The run's bundle with the lid open: chassis fan → over the hinge edge, above every chassis part → the turned lid fan."""
    conds = run["conductors"]
    ends = [(c[3], Turned(c[4])) if shell(c[3]) == "bottom" else (c[4], Turned(c[3])) for c in conds]
    fa, fb = fan([e[0] for e in ends]), fan([e[1] for e in ends])
    ia, ib = onearest(fa), onearest(fb)
    if not (ia and ib):
        return None
    best = None
    for x_ in sorted({round(v / OS) * OS for v in (fa[0], fb[0], (fa[0] + fb[0]) / 2)}):   # where along the hinge edge it crosses
        over = [x_, hinge_y, z_over]
        iw = onearest(over)
        p1 = oastar(ia, iw) if iw else None
        p2 = oastar(iw, ib) if p1 else None
        if p1 and p2:
            trunk = [r2(fa)] + [r2(opos(v)) for v in smooth(p1 + p2[1:])] + [r2(fb)]
            if not best or plen(trunk) < plen(best["trunk"]):
                best = {"ends": ends, "trunk": trunk, "over": opos(iw)}
    return best


for run in runs:
    if run["routed"] and run["crosses"]:
        run["open"] = route_open(run)

OPEN_BOXES = []                                       # every part's box with the lid open (the lid's turned over), exact
for c in L["comps"]:
    (x0, y0, z0), (sx, sy, sz) = c["at"], c["size"]
    if owner_shell(c) == "top":
        y0, y1, z0, z1 = 2 * hinge_y - (y0 + sy), 2 * hinge_y - y0, 2 * hinge_z - (z0 + sz), 2 * hinge_z - z0
    else:
        y1, z1 = y0 + sy, z0 + sz
    OPEN_BOXES.append((c["name"], (x0, x0 + sx, y0, y1, z0, z1)))


def olink(p_, q_, own=()):
    """link(), with the lid open: straight if no part of the open pose is in the way, else round them on the open grid."""
    if not open_hits([p_, q_], own, trim=0):
        return []
    ia, ib = onearest(p_), onearest(q_)
    path = oastar(ia, ib) if ia and ib else None
    return [r2(opos(v)) for v in smooth(path)] if path else []


def open_hits(P, own, trim=6):
    """The parts a wire's open path passes through, its own end parts' boxes aside (it starts inside their envelopes),
    though not their bosses or straps; and nothing within 3 mm of its pads, where it leaves them."""
    R = resample(P, 0.5)
    R = R[trim:len(R) - trim] if trim else R
    skip = set(own) | {n for n in COMPS if any(n.startswith(SAME_) for SAME_ in ("radxa ",) if "radxa pcb" in own)}
    out = set()
    for q in R:
        for n, (a_, b_, c_, d_, e_, f_) in OPEN_BOXES:
            if n not in skip and a_ < q[0] < b_ and c_ < q[1] < d_ and e_ < q[2] < f_:
                out.add(n)
    return sorted(out)


def resample(pts, step=1.0):
    out = [np.array(pts[0], float)]
    for a_, b_ in zip(pts, pts[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


def slacken(trunk, target_extra, over):
    """Take up `target_extra` mm of a run's spare by lifting its trunk into an arch where it crosses the hinge edge:
    every lifted point only rises, and only where it stays in free space. Returns the new trunk and the mm taken up."""
    P = resample(trunk, 1.0)
    iw = int(np.argmin([np.linalg.norm(q - np.array(over)) for q in P]))
    base = plen(P)
    for R in (60, 40, 25, 15):
        lo_i, hi_i = max(1, iw - R), min(len(P) - 2, iw + R)
        def lifted(h):
            Q = [q.copy() for q in P]
            for i in range(lo_i, hi_i + 1):
                t = (i - lo_i) / max(1, hi_i - lo_i)
                Q[i][2] += h * math.sin(math.pi * t)
            return Q
        lo, hi = 0.0, 300.0
        if plen(lifted(hi)) - base < target_extra:
            continue
        for _ in range(40):
            mid = (lo + hi) / 2
            if plen(lifted(mid)) - base < target_extra:
                lo = mid
            else:
                hi = mid
        Q = lifted(hi)
        if all(free_at(q) for q in Q[lo_i:hi_i + 1]):
            return [r2(q) for q in Q], plen(Q) - base
    return [r2(q) for q in P], 0.0


BOXES = [(c["name"], np.array(c["at"]), np.array(c["at"]) + np.array(c["size"])) for c in L["comps"] if c.get("role") != "keep-out"]


def seg_free(p_, q_, own=()):
    """A straight piece of wire is clear: every 0.25 mm along it is in the cavity and outside every part's box (its own
    end parts aside, whose envelopes its pads sit in)."""
    n = max(2, int(math.dist(p_, q_) / 0.25))
    for j in range(n + 1):
        t = j / n
        q = np.array([p_[k] + (q_[k] - p_[k]) * t for k in range(3)])
        i = idx(q)
        if not all(0 <= i[k] < free.shape[k] for k in range(3)) or not cav_ok[i]:
            return False
        if any(nm not in own and (lo < q).all() and (q < hi).all() for nm, lo, hi in BOXES):
            return False
    return True


def link(p_, q_, own=()):
    """From a pad's lift point to its place in the bundle: straight if that is clear, else routed round what's in the way."""
    if seg_free(p_, q_, own):
        return []
    ia, ib = nearest_free(p_), nearest_free(q_)
    path = astar(ia, ib) if ia and ib else None
    return [r2(pos(v)) for v in smooth(path)] if path else []


def conductor_pts(trunk, strand, a, b, linker=None):
    """Pad → straight up off its board → (round anything in the way) → the bundle → … → its far pad, the same way."""
    linker = linker or link
    la, lb = [r2(np.array(e.at) + np.array(e.dir) * e.lift) for e in (a, b)]
    s0, s1 = r2(strand[0]), r2(strand[-1])
    own = {a.part, b.part}
    return [r2(a.at), la] + linker(la, s0, own) + [r2(q) for q in strand] + linker(s1, lb, own) + [lb, r2(b.at)]


WORK = 30   # ADR-0021: working slack on every soldered wire, so a board can be lifted ≈ 25 mm off its bosses to solder it


def cut_of(length, gauge, open_len=None):
    """Routed × 1.1 + 10 mm (stripped ends and bends) + 30 mm to work with (a bought cable has its own length); a run to
    the lid also reaches with the lid laid open: its open route plus the same ends and bends. Rounded up to 10 mm."""
    allow = length * 0.1 + 10
    cut = int(math.ceil((length + allow + (0 if gauge == "qt" else WORK)) / 10) * 10)
    if open_len:
        cut = max(cut, int(math.ceil((open_len + allow) / 10) * 10))
    return cut, open_len


# ---------------------------------------------------------------- slack: every wire at its cut length, closed too
# A wire is cut longer than its route (+10 % and 10 mm; a run across the seam, enough to open the lid flat). In the
# closed case that extra is folded into a flat service loop, an accordion of short folds laid side by side, which is
# how a builder stows it: by the lid connector for a run that crosses the seam (that slack unfolds when the lid
# opens), mid-way along any other. The loop must sit in free space, clear of parts and of every other wire and loop.
AXES = [(0, 1, 2), (1, 0, 2), (0, 2, 1), (2, 0, 1), (1, 2, 0), (2, 1, 0)]   # (fold along, stack along, thickness)


DIRS26 = [np.array(d_, float) / np.linalg.norm(d_) for d_ in ((dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)) if any(d_)]


def hairpin(trunk, i_a, excess, run):
    """The spare bent out and back beside the run, as a builder lays it where there's no room for folds: a straight
    spur of half the spare from a point on the run, clear of parts and other wires, and back 1.5 mm beside it."""
    a_ = np.array(trunk[i_a], float)
    r_ = excess / 2
    need = 0.5 * 1.15 * math.sqrt(run["d2"]) + 0.3
    for d_ in sorted(DIRS26, key=lambda v: -abs(v[2])):    # up or down first: along the floor or the roof
        tip = a_ + d_ * r_
        side = np.cross(d_, [0, 0, 1.0]) if abs(d_[2]) < 0.9 else np.cross(d_, [1.0, 0, 0])
        side = side / np.linalg.norm(side) * 1.5
        back = a_ + side * 0.2
        if not (seg_free(a_, tip) and seg_free(tip + side, back)):
            continue
        cells = [idx(a_ + d_ * t + s_) for t in np.linspace(1.0, r_, max(2, int(r_))) for s_ in (0 * side, side)]
        if any(not all(0 <= c_[k] < free.shape[k] for k in range(3)) or clear[c_] < need or use[c_] > 0 for c_ in cells):
            continue
        for c_ in cells:
            use[c_] += run["d2"]
        SAT_VER[0] += 1
        return trunk[:i_a + 1] + [r2(tip), r2(tip + side), r2(back)] + trunk[i_a + 1:], tip
    return None


SAT, SAT_VER = {"ver": -1}, [0]
import os  # noqa: E402
DEBUG_LOOPS = set(filter(None, os.environ.get("GF_DEBUG_LOOPS", "").split("|")))


def place_loop(anchor, excess, d):
    """A flat accordion of folds holding `excess` mm, in free space clear of other wires, as near `anchor` as it fits.
    d: the run's wires as one equivalent diameter (√Σd²); the loop's room allows for the bundle round it."""
    d0, d = d, 1.15 * d + 0.2     # the bundle's own diameter (its wires sit inside it, strands())
    pitch = max(d, 1.0) + 0.4
    ai = np.array(idx(anchor))
    if SAT["ver"] != SAT_VER[0]:                     # summed-area table of what's taken: any box's count in 8 lookups
        blocked = (~free | (use > 0)).astype(np.int32)
        SAT["sat"] = np.zeros(tuple(n + 1 for n in blocked.shape), np.int64)
        SAT["sat"][1:, 1:, 1:] = blocked.cumsum(0).cumsum(1).cumsum(2)
        SAT["ver"] = SAT_VER[0]
    sat = SAT["sat"]
    offs = np.array(sorted(((dx, dy, dz) for dx in range(-48, 49, 2) for dy in range(-48, 49, 2) for dz in range(-14, 15, 2)),
                           key=lambda o: o[0] ** 2 + o[1] ** 2 + o[2] ** 2))
    shape = np.array(free.shape)
    for f in (14.0, 10.0, 7.0, 5.0):
        k = max(1, int(math.ceil(excess / f)))
        for ax in AXES:
            size = [0, 0, 0]
            size[ax[0]], size[ax[1]], size[ax[2]] = f + 2, k * pitch + 2, d + 1.0
            nv = np.array([max(1, int(math.ceil(v / STEP))) for v in size])
            lo = ai + offs
            hi = lo + nv
            ok = (lo >= 0).all(1) & (hi <= shape).all(1)
            if not ok.any():
                continue
            L_, H_ = lo[ok], hi[ok]
            g = lambda x_, y_, z_: sat[x_, y_, z_]
            cnt = (g(H_[:, 0], H_[:, 1], H_[:, 2]) - g(L_[:, 0], H_[:, 1], H_[:, 2]) - g(H_[:, 0], L_[:, 1], H_[:, 2]) - g(H_[:, 0], H_[:, 1], L_[:, 2])
                   + g(L_[:, 0], L_[:, 1], H_[:, 2]) + g(L_[:, 0], H_[:, 1], L_[:, 2]) + g(H_[:, 0], L_[:, 1], L_[:, 2]) - g(L_[:, 0], L_[:, 1], L_[:, 2]))
            hit = np.nonzero(cnt == 0)[0]
            if not len(hit):
                continue
            lo_ = L_[hit[0]]
            sl = tuple(slice(int(a_), int(a_ + n_)) for a_, n_ in zip(lo_, nv))
            org = np.array(pos(tuple(lo_))) + 1.0
            org[ax[2]] = pos(tuple(lo_))[ax[2]] + size[ax[2]] / 2     # the folds in the middle of the loop's thickness
            pts_ = []
            left = excess
            for j in range(k):
                run = min(f, left)
                a0 = [0.0, 0.0, 0.0]; a0[ax[1]] = j * pitch; a0[ax[0]] = 0.0 if j % 2 == 0 else f
                a1 = list(a0); a1[ax[0]] = a0[ax[0]] + (run if j % 2 == 0 else -run)
                pts_ += [list(org + np.array(a0)), list(org + np.array(a1))]
                left -= run
                if left <= 0:
                    break
            use[sl] += d0 * d0
            SAT_VER[0] += 1
            return [[round(float(v), 2) for v in q] for q in pts_], f, k, sl
    return None


unstowed = []
for run in runs:
    if not run["routed"]:
        continue
    conds, trunk = run["conductors"], run["trunk"]
    S = strands(trunk, conds)
    geo = []
    for c, st in zip(conds, S):
        pts = conductor_pts(trunk, st, c[3], c[4])
        length = plen(pts)
        ol = None
        if run.get("open"):                               # this wire's own open route: its pads, the bundle's path
            k = conds.index(c)
            so = strands(run["open"]["trunk"], conds)[k]
            ol = plen(conductor_pts(run["open"]["trunk"], so, *run["open"]["ends"][k], linker=olink))
        cut, clam = cut_of(length, c[2], ol)
        geo.append({"pts": pts, "length": length, "cut": cut, "clam": clam})
    if run["crosses"]:                                    # the pigtails of one run are cut alike
        cmax = max(g["cut"] for g in geo)
        for g in geo:
            g["cut"] = cmax
    # spare: what a wire carries beyond its route, + 10 % and 10 mm (that much is for the ends and bends): the working
    # slack, and on a run to the lid what the open lid takes. Closed, it folds into a loop
    gov = max(geo, key=lambda g: g["cut"] - (g["length"] * 1.1 + 10))
    excess = gov["cut"] - (gov["length"] * 1.1 + 10) if conds[0][2] != "qt" else 0.0   # a bought cable: its spare isn't drawn
    run["trunk_closed"] = trunk
    n_ = len(trunk) - 1
    if excess >= 4:
        e_ = (0 if run["lid_end"] == 0 else n_) if run["crosses"] else n_ // 2   # by the lid connector first (the slack the lid takes when it opens); else mid-way
        anchors = sorted(range(n_ + 1), key=lambda i: abs(i - e_))   # every point along the run, from there out
        done = False
        for i_a in dict.fromkeys(anchors):
            target = excess
            for _ in range(6):                             # the hops to and from the loop add length: correct and re-place
                if target < 2:
                    break
                got = place_loop(trunk[i_a], target, math.sqrt(run["d2"]))
                if not got:
                    break
                loop, f, k, sl = got
                ct = trunk[:i_a + 1] + link(trunk[i_a], loop[0]) + loop + link(loop[-1], trunk[i_a]) + trunk[i_a:]   # to the loop and back, round parts
                err = (plen(ct) - plen(trunk)) - excess
                if DEBUG_LOOPS and run["run"] in DEBUG_LOOPS:
                    print("loop", run["run"], "anchor", i_a, "target", round(target, 1), "err", round(err, 1), "at", [round(v) for v in loop[0]], "from", [round(v) for v in trunk[i_a]])
                if abs(err) <= 2.0:
                    run["trunk_closed"] = ct
                    run["loop"] = {"at": [round(float(v), 1) for v in np.mean(np.array(loop), axis=0)], "fold_mm": f, "folds": k, "mm": round(target, 1)}
                    done = True
                    break
                use[sl] -= run["d2"]                       # take it back out and try again with the error removed
                SAT_VER[0] += 1
                target -= err
            if done:
                break
        for i_a in ([] if done else dict.fromkeys(anchors)):   # no room for folds anywhere along it: a hairpin
            hp = hairpin(trunk, i_a, excess, run)
            if hp:                                         # the spare as a hairpin, out and back beside the run
                ct, tip = hp
                run["trunk_closed"] = ct
                run["loop"] = {"at": [round(float(v), 1) for v in tip], "fold_mm": round(excess / 2, 1), "folds": 1, "mm": round(excess, 1), "hairpin": True}
                done = True
                break
        if not done:
            unstowed.append(run["run"])
    SC = strands(run["trunk_closed"], conds)
    for g, st, c in zip(geo, SC, conds):
        g["closed"] = conductor_pts(run["trunk_closed"], st, c[3], c[4])
    run["geo"] = geo
    if run.get("open"):        # the same wires, lid open: the spare the closed loop held rises as an arch over the hinge edge
        o = run["open"]
        k = min(range(len(geo)), key=lambda i: plen(geo[i]["closed"]) - geo[i]["clam"])
        extra = plen(geo[k]["closed"]) - geo[k]["clam"]
        t2, _ = slacken(o["trunk"], extra, o["over"]) if extra > 1 else (o["trunk"], 0.0)
        for g, st, e in zip(geo, strands(t2, conds), o["ends"]):
            g["open"] = conductor_pts(t2, st, *e, linker=olink)
        run["open_short"] = extra < -2.0               # the closed loop is placed to ±2 mm of its length
        run["open_clash"] = [f"{c[0]} ({', '.join(h_)})" for g, c in zip(geo, conds) for h_ in [open_hits(g["open"], {c[3].part, c[4].part})] if h_]

# ---------------------------------------------------------------- the conductors, the pads they reach, the runs
pin_users = {}                                        # (part, pad) → the conductors on it
for run in runs:
    for c in run["conductors"]:
        for e in (c[3], c[4]):
            pin_users.setdefault((e.part, e.pad), []).append(c[0])


def via(run):
    """For a run to or from the lid connector: the whole line, through the connector, e.g. Radxa → lid connector → mics."""
    if LIDC not in [run["conductors"][0][3].part, run["conductors"][0][4].part]:
        return None
    pins = {e.pad for c in run["conductors"] for e in (c[3], c[4]) if e.part == LIDC}
    other = sorted({nice(e) for r in runs if r is not run for c in r["conductors"] for e in (c[3], c[4])
                    if any(x.part == LIDC and x.pad in pins for x in (c[3], c[4])) and e.part != LIDC})
    me = sorted({nice(e) for c in run["conductors"] for e in (c[3], c[4]) if e.part != LIDC})
    low, lid = (me, other) if run["shell"] == "cross" else (other, me)
    return f"{', '.join(low)} → lid connector → {', '.join(lid)}"


routes = []
for run in runs:
    v = via(run)
    for i, c in enumerate(run["conductors"]):
        label, kind, gauge, a, b, inline = c
        rec = {"net": label, "run": run["run"], "kind": kind, "color": COL[kind], "gauge": gauge, "gauge_label": GAUGE[gauge], "d_mm": D[gauge], "routed": run["routed"],
               "ends": [a.part, b.part], "between": [nice(a), nice(b)],
               "pads": [{"part": e.part, "pad": e.pad, "label": e.label, "joint": e.joint} for e in (a, b)]}
        if run["routed"]:
            g = run["geo"][i]
            rec.update({"points": g["pts"], "closed_points": g["closed"], "length_mm": round(g["length"], 1), "cut_mm": g["cut"],
                        "closed_mm": round(plen(g["closed"]), 1), "allowance_mm": round(g["length"] * 0.1 + 10, 1),
                        "crosses_seam": run["crosses"], "shell": run["shell"], **({"lid_end": run["lid_end"]} if run["crosses"] else {}),
                        **({"clam_mm": round(g["clam"], 1)} if g["clam"] else {}), **({"loop": run["loop"]} if run.get("loop") else {}),
                        **({"open_points": g["open"], "open_mm": round(plen(g["open"]), 1)} if g.get("open") else {}),
                        **({"path": v} if v else {})})
            if inline:
                rec["inline"] = {"part": inline[0], "t": 0.18 if inline[1] == "a" else 0.82, "len": inline[2] if len(inline) > 2 else 8}
        routes.append(rec)
run_recs = [{"run": r["run"], "routed": r["routed"], "conductors": [c[0] for c in r["conductors"]], "crosses_seam": r["crosses"],
             "ends": sorted({e.part for c in r["conductors"] for e in (c[3], c[4])}),
             **({"cut_mm": max(g["cut"] for g in r["geo"]), "length_mm": round(max(g["length"] for g in r["geo"]), 1),
                 "clam_mm": round(max(g["clam"] or 0 for g in r["geo"]), 1)} if r["routed"] else {}),
             **({"open_routed": bool(r.get("open")), "open_short": r.get("open_short", True), "open_clash": r.get("open_clash", [])} if r["crosses"] else {}),
             **({"loop": r["loop"]} if r.get("loop") else {})} for r in runs]

# bundles: where wires share voxels, the bundle's diameter ≈ 1.15·√Σd² must fit in the clearance around it
bundle_d = 1.15 * np.sqrt(use)
tight = (use > 0) & (bundle_d / 2 > clear + 0.5)            # half a voxel of slack for the grid
worst = float((bundle_d / 2 - clear)[use > 0].max()) if (use > 0).any() else 0.0
tight_at = [[round(float(v), 1) for v in pos(tuple(i))] for i in np.argwhere(tight)[:10]]
max_bundle = int(round(float(use.max()) / (D[30] ** 2))) if use.max() else 0
summary = {
    "wires": len(routes), "runs": len(runs), "routed": sum(r["routed"] for r in routes),
    "unrouted": [r["net"] for r in routes if not r["routed"]],
    "pads": sum(1 for k in pin_users), "pads_shared": {f"{k[0]} {k[1]}": v for k, v in pin_users.items() if len(v) > 1},
    "tight_voxels": int(tight.sum()), "worst_overlap_mm": round(worst, 2), "tight_at": tight_at,
    "max_bundle_30awg_equiv": max_bundle,
    "seam_wires": [r["run"] for r in run_recs if r["crosses_seam"]],
    "seam_conductors": sum(len(r["conductors"]) for r in run_recs if r["crosses_seam"]),
    "longest_seam_mm": max([r["length_mm"] for r in run_recs if r["crosses_seam"] and r["routed"]] or [0]),
    "total_length_mm": round(sum(r.get("length_mm", 0) for r in routes), 0),
    "total_cut_mm": round(sum(r.get("cut_mm", 0) for r in routes if r.get("routed")), 0),
    "loops": sum(1 for r in run_recs if r.get("loop")), "unstowed": unstowed, "work_mm": WORK,
    "open_unrouted": [r["run"] for r in run_recs if r["crosses_seam"] and not r.get("open_routed")],
    "open_short": [r["run"] for r in run_recs if r.get("open_short")],
    "open_clash": [f"{r['run']}: {', '.join(r['open_clash'])}" for r in run_recs if r.get("open_clash")],
}
json.dump({"summary": summary, "runs": run_recs, "wires": routes, "boards": boards_json(L, pin_users)},
          open(HERE / "out_pebble" / "wires.json", "w"), indent=1, ensure_ascii=False)
print(json.dumps(summary, indent=1, ensure_ascii=False))
# the cut list goes into wiring.md between its markers: one row per wire, grouped by run, pad to pad
rows, last = [], None
for r in routes:
    if not r["routed"]:
        continue
    pa, pb_ = r["pads"]
    rows.append(f"| {r['run'] if r['run'] != last else ''} | {r['net']}: {r['between'][0]} **{pa['label']}** → {r['between'][1]} **{pb_['label']}** | "
                f"{r['gauge_label']} | {r['length_mm']:.0f} | {'yes' if r['crosses_seam'] else ''} | **{r['cut_mm']}** |")
    last = r["run"]
table = ("<!-- cutlist -->\n| Run | Wire, pad to pad | AWG | Routed in the closed case (mm) | To the lid | Cut (mm) |\n|---|---|---|---|---|---|\n" + "\n".join(rows) +
         "\n\nCut = routed pad to pad × 1.1 + 10 mm for the stripped ends and bends, + 30 mm to work with (enough to lift a board ≈ 25 mm off "
         "its bosses while you solder it), rounded up to 10 mm; a bought STEMMA QT cable has its own length. A run to the lid is also long "
         "enough to reach with the lid laid open beside the chassis: routed that way too, clear of every part (ADR-0021). Its wires are cut alike. "
         "Closed, every wire's spare folds into a small loop (by the lid connector for a run to the lid). From `cad/wire_routes.py`; drawn in the "
         "site's 3D model (Wires; Pins for the pad names; Open for the lid laid open).\n<!-- /cutlist -->")
wmd = HERE.parent / "wiring.md"
t = wmd.read_text()
if "<!-- cutlist -->" in t:
    t = t[:t.index("<!-- cutlist -->")] + table + t[t.index("<!-- /cutlist -->") + len("<!-- /cutlist -->"):]
    wmd.write_text(t)
for r in routes:
    print(f"{'ok ' if r['routed'] else 'NO '} {r['run'][:34]:34s} {r['net'][:34]:34s} {r.get('length_mm', '—'):>7} mm {'seam' if r.get('crosses_seam') else ''}")

import stamps  # noqa: E402
stamps.write("wires")
