main
John Lauer README: component table per the wiki format, open-source section, no duplicate hero; generators at gen/ 39a59ef 7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
#!/usr/bin/env python3
"""High-speed routing for the OCP NIC 3.0 SFF -> 4x OCuLink riser.

Board frame: x across the card (0..76), y from the faceplate edge (0) to the gold-finger edge (115), KiCad y-down.
Layers: L1=F.Cu, L2=In1 GND, L3=In2, L4=In3 GND, L5=In4, L6=B.Cu GND (and the A-side fingers).

Each differential pair is a centreline of nodes (x, y, layer). Consecutive nodes on one layer are a trace; a layer
change is a via pair placed across the direction of travel. The n trace is the left offset (west when heading north),
p the right one; that holds the whole way because every path is an octilinear S-shape.

Lane reversal (OCuLink ports face the faceplate, so each port sees its lanes in the opposite order to the fingers):
  stage A (just past the via row): lanes 0<->1 and 2<->3 cross; lane 0 on L1, lane 1 on L3, lane 3 on L1, lane 2 on L5
  lane 3 drops L1 -> L5 in band M
  stage B (before the connector): block {1,0} (L3, L1) crosses block {3,2} (both L5)
"""
import math, json, os, sys
sys.path.insert(0, os.path.dirname(__file__))
from footprints import ocp_pin_x

F = 38.0 - 0.258          # datum F: H (card centre line, x=38) minus 0.258 (SFF-TA-1002 Fig 5-39, OCP Detail A)
EDGE_Y = 115.0
PAD_TOP = EDGE_Y - 3.00   # inner end of every gold finger
CX = [16.1 + 14.6 * k for k in range(4)]   # OCuLink port centres (pitch 14.6, inside the 59.1 mm faceplate zone)

# 85 ohm differential, JLC06161H-3313 (design/zsolve.py, 2D field solve)
PAIR = {"L1": (0.163, 0.15), "L6": (0.163, 0.15), "L3": (0.131, 0.15), "L5": (0.121, 0.15)}
KLAY = {"L1": "F.Cu", "L3": "In2.Cu", "L5": "In4.Cu", "L6": "B.Cu"}
VIA_P = 0.70               # signal via pair pitch
VIA_D, VIA_H = 0.40, 0.20  # transition vias
VIP_D, VIP_H = 0.25, 0.15  # via-in-pad at the OCuLink pads (JLC 6L: filled and capped by default)


def pitch(layer):
    w, s = PAIR[layer]
    return w + s


class Pair:
    def __init__(self, name, nnet, pnet, kind):
        self.name, self.nnet, self.pnet, self.kind = name, nnet, pnet, kind
        self.nodes = []          # (x, y, layer)
        self.start = None        # ((xn, yn), (xp, yp), layer)  pad ends
        self.end = None          # ((xn, yn), (xp, yp), layer, via_in_pad)
        self.tracks = []         # (layer, net, x1, y1, x2, y2, w)
        self.vias = []           # (x, y, net, d, h)

    def add(self, x, y, layer):
        self.nodes.append((round(x, 4), round(y, 4), layer))
        return self


def unit(dx, dy):
    L = math.hypot(dx, dy)
    return (dx / L, dy / L)


def left(d):
    return (d[1], -d[0])


def offset_polyline(pts, off):
    """Mitred offset of an octilinear polyline (left side positive)."""
    out = []
    n = len(pts)
    for i in range(n):
        if i == 0:
            d = unit(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); l = left(d)
            out.append((pts[0][0] + l[0] * off, pts[0][1] + l[1] * off))
        elif i == n - 1:
            d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)
            out.append((pts[-1][0] + l[0] * off, pts[-1][1] + l[1] * off))
        else:
            d1 = unit(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])
            d2 = unit(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
            l1, l2 = left(d1), left(d2)
            bis = (l1[0] + l2[0], l1[1] + l2[1])
            bl = math.hypot(*bis)
            if bl < 1e-9:
                raise ValueError("reversal in path")
            bis = (bis[0] / bl, bis[1] / bl)
            cosh = bis[0] * l1[0] + bis[1] * l1[1]
            k = off / cosh
            out.append((pts[i][0] + bis[0] * k, pts[i][1] + bis[1] * k))
    return out


def build(pair):
    """Turn a pair's centreline nodes into n/p tracks and vias."""
    nodes = []
    for nd in pair.nodes:
        if nodes and abs(nd[0] - nodes[-1][0]) < 1e-6 and abs(nd[1] - nodes[-1][1]) < 1e-6 and nd[2] == nodes[-1][2]:
            continue
        nodes.append(nd)
    # drop collinear middle points on the same layer
    clean = [nodes[0]]
    for i in range(1, len(nodes) - 1):
        a, b, c = clean[-1], nodes[i], nodes[i + 1]
        if a[2] == b[2] == c[2]:
            cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
            dot = (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1])
            if abs(cr) < 1e-9 and dot > 0:
                continue
        clean.append(b)
    clean.append(nodes[-1])
    nodes = clean
    pair.nodes = nodes
    # split into runs per layer
    runs = []
    cur = [nodes[0]]
    for nd in nodes[1:]:
        if nd[2] == cur[-1][2]:
            cur.append(nd)
        else:
            runs.append(cur)
            cur = [nd]
    runs.append(cur)
    vias = []  # positions between runs: last node of run i == first node xy of run i+1
    for i in range(len(runs) - 1):
        a = runs[i][-1]; b = runs[i + 1][0]
        assert abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6, (pair.name, a, b)
    for ri, run in enumerate(runs):
        layer = run[0][2]
        p = pitch(layer); w = PAIR[layer][0]
        pts = [(x, y) for (x, y, _) in run]
        # entry and exit fans to via pitch
        fan_in = ri > 0
        fan_out = ri < len(runs) - 1
        for sign, net in ((+1, pair.nnet), (-1, pair.pnet)):
            off = sign * p / 2
            line = offset_polyline(pts, off)
            if fan_in:
                d = unit(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); l = left(d)
                vx, vy = pts[0][0] + l[0] * sign * VIA_P / 2, pts[0][1] + l[1] * sign * VIA_P / 2
                f = (VIA_P - p) / 2
                line[0] = (line[0][0] + d[0] * f, line[0][1] + d[1] * f)
                line.insert(0, (vx, vy))
            if fan_out:
                d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)
                vx, vy = pts[-1][0] + l[0] * sign * VIA_P / 2, pts[-1][1] + l[1] * sign * VIA_P / 2
                f = (VIA_P - p) / 2
                line[-1] = (line[-1][0] - d[0] * f, line[-1][1] - d[1] * f)
                line.append((vx, vy))
            # pad ends
            if ri == 0 and pair.start is not None:
                padpt = pair.start[0] if sign > 0 else pair.start[1]
                line = pad_join(padpt, line, at_start=True, p=p)
            if ri == len(runs) - 1 and pair.end is not None:
                padpt = pair.end[0] if sign > 0 else pair.end[1]
                line = pad_join(padpt, line, at_start=False, p=p)
            for (x1, y1), (x2, y2) in zip(line, line[1:]):
                if math.hypot(x2 - x1, y2 - y1) > 1e-6:
                    pair.tracks.append((layer, net, x1, y1, x2, y2, w))
        if fan_out:
            d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)
            for sign, net in ((+1, pair.nnet), (-1, pair.pnet)):
                pair.vias.append((pts[-1][0] + l[0] * sign * VIA_P / 2, pts[-1][1] + l[1] * sign * VIA_P / 2, net, VIA_D, VIA_H))
    if pair.end is not None and pair.end[3]:
        for (pt, net) in ((pair.end[0], pair.nnet), (pair.end[1], pair.pnet)):
            pair.vias.append((pt[0], pt[1], net, VIP_D, VIP_H))
    return pair


def pad_join(padpt, line, at_start, p):
    """Join a trace end to a pad point with a 45 degree jog (pads are on the pair's axis line)."""
    if at_start:
        q = line[0]
        dx, dy = q[0] - padpt[0], q[1] - padpt[1]
        # go straight along the major axis, then 45 degrees
        if abs(dx) < 1e-6:
            return [padpt] + line
        s = 1 if dy > 0 else -1
        mid = (padpt[0], q[1] - s * abs(dx))
        return [padpt, mid] + line
    else:
        q = line[-1]
        dx, dy = padpt[0] - q[0], padpt[1] - q[1]
        if abs(dx) < 1e-6:
            return line + [padpt]
        s = 1 if dy > 0 else -1
        mid = (padpt[0], q[1] + s * abs(dx)) if abs(dy) > abs(dx) else None
        # move diagonally first, then straight onto the pad
        mid = (q[0] + dx, q[1] + s * abs(dx))
        return line + [mid, padpt]


# ------------------------------------------------------------------------------------------ geometry plan
PX = ocp_pin_x()


def finger(pin):
    return (F + PX[pin], PAD_TOP)


Y_FAN0 = 107.6      # fan diagonals start (clear of the key slots, which end at y = 108.0)
Y_VIA = 97.6        # transition via row
Y_SA0 = 96.9        # stage A start
LANE_PITCH = 2.8
WITHIN = 0.7        # P/R offset from the lane cell centre after the via row
# lane cell centres at the via row
XC = []
x = 21.0
for k in range(4):
    row = []
    for l in range(4):
        row.append(x + l * LANE_PITCH)
    XC.append(row)
    x = row[-1] + LANE_PITCH + 1.5

# which side of its cell each pair sits on after the via row (P first, R second), per lane index in port
#   b0: P west / R east, b1: R west / P east, b2: P west / R east, b3: R west / P east
SIDE = {0: (-1, +1), 1: (+1, -1), 2: (-1, +1), 3: (+1, -1)}
# layer of each bundle in band S and at the connector
LAYER_S = {0: "L1", 1: "L3", 2: "L5", 3: "L1"}
LAYER_C = {0: "L1", 1: "L3", 2: "L5", 3: "L5"}

# connector-end pair centres relative to the port centre, and how each pair ends
#   P pairs land on B-row pads, R pairs pass the B row through a gap and land on A-row pads
CONN = {
    # lane: (P centre, R gap x, R pad centre)
    0: (4.0, 4.0, 3.5),
    1: (2.5, 1.0, 2.0),
    2: (-2.0, -0.5, -2.5),
    3: (-3.5, -5.15, -4.0),
}
Y_BROW, Y_AROW = 7.37, 5.18
Y_STRIP = 6.27          # between the A and B rows, where R pairs turn to their A pads
Y_NB_END = 13.0         # stage B finished: pairs at their connector x
Y_NB = 19.5             # stage B starts (block swap, 45 degree)


def lane_pins(lane):
    starts = [17, 20, 23, 26, 30, 33, 36, 39, 44, 47, 50, 53, 56, 59, 62, 65]
    s = starts[lane]
    return f"{s}", f"{s+1}"


# band M straight-section positions relative to the port centre (before the band-M2 shift back west)
#   order W->E: R1, P1, P0, R0 | crossing-via gap | R3, P3, P2, R2
XEND = {("R", 1): 1.0, ("P", 1): 2.5, ("P", 0): 4.0, ("R", 0): 5.2,
        ("R", 3): -5.15, ("P", 3): -3.5, ("P", 2): -2.0, ("R", 2): -0.5}
D_SWAP = 6.0
def xm_rel(kind, l):
    x = XEND[(kind, l)]
    return x - D_SWAP if l in (0, 1) else x + D_SWAP

S_EAST = 6.0            # band M corridors sit this far east of their connectors
Y_SW = 93.4             # lane 3 drops L1 -> L5 here
Y_M0 = 92.6             # band M diagonals start
Y_M2 = 36.0             # band M2: shift back west by S_EAST
Y_NB = Y_NB_END + D_SWAP


def plan_lane(k, l):
    """(P pair, R pair) for port k, port-lane l, finger to OCuLink pad."""
    lane = 4 * k + l
    n, p = lane_pins(lane)
    P = Pair(f"P{k}{l}", f"PETn{lane}", f"PETp{lane}", "PET")
    R = Pair(f"R{k}{l}", f"PERn{lane}", f"PERp{lane}", "PER")
    cx = CX[k]
    xc_f = F + PX["B" + n] + 0.3
    sP, sR = SIDE[l]
    partner = {0: 1, 1: 0, 2: 3, 3: 2}[l]
    for pr, kind, s_side, startlayer in ((P, "P", sP, "L1"), (R, "R", sR, "L6")):
        xv = XC[k][l] + s_side * WITHIN
        xa = XC[k][partner] + s_side * WITHIN
        xm = cx + S_EAST + xm_rel(kind, l)
        xn = cx + xm_rel(kind, l)
        xe = cx + XEND[(kind, l)]
        pin_n = ("B" if kind == "P" else "A") + n
        pin_p = ("B" if kind == "P" else "A") + p
        pr.start = (finger(pin_n), finger(pin_p), startlayer)
        pc = pitch(startlayer)
        y0 = PAD_TOP - 0.25 - (0.6 - pc) / 2
        pr.add(xc_f, y0, startlayer)
        pr.add(xc_f, Y_FAN0, startlayer)
        pr.add(xv, Y_FAN0 - abs(xv - xc_f), startlayer)
        lay = LAYER_S[l]
        pr.add(xv, Y_VIA, startlayer)
        if lay != startlayer:
            pr.add(xv, Y_VIA, lay)
        pr.add(xv, Y_SA0, lay)
        pr.add(xa, Y_SA0 - abs(xa - xv), lay)
        if l == 3:                      # lane 3 drops to L5 for stage B
            pr.add(xa, Y_SW, lay)
            pr.add(xa, Y_SW, "L5")
            lay = "L5"
        pr.add(xa, Y_M0, lay)
        pr.add(xm, Y_M0 - abs(xm - xa), lay)
        for (tx, ty) in TUNE.get(pr.name, []):
            pr.add(tx, ty, lay)
        pr.add(xm, Y_M2, lay)
        pr.add(xn, Y_M2 - abs(xn - xm), lay)
        pr.add(xn, Y_NB, lay)
        pr.add(xe, Y_NB_END, lay)
        connector_end(pr, kind, l, cx, lay)
    return P, R


def connector_end(pr, kind, l, cx, lay):
    xe = cx + XEND[(kind, l)]
    if kind == "P":
        pads = (cx + 5.25 - 0.5 * (b_pin(l, "n") - 1), cx + 5.25 - 0.5 * (b_pin(l, "p") - 1))
        xc = (pads[0] + pads[1]) / 2
        assert abs(xc - xe) < 1e-6, (pr.name, xc, xe)
        pr.add(xe, Y_BROW + 0.45, lay)
        pr.end = ((pads[0], Y_BROW), (pads[1], Y_BROW), lay, lay != "L1")
        return
    # R pairs: L1 (lane 0) drops to L3 first, then the B-row gap, the strip, the A pads
    if lay == "L1":
        yv = 11.6
        pr.add(xe, yv, "L1")
        pr.add(xe, yv, "L3")
        lay = "L3"
        xg = cx + CONN[l][1]
        pr.add(xe, yv - 0.6, lay)
        pr.add(xg, yv - 0.6 - abs(xg - xe), lay)
    xg = cx + CONN[l][1]
    xa = cx + CONN[l][2]
    pads = (cx + 4.75 - 0.5 * (a_pin(l, "n") - 1), cx + 4.75 - 0.5 * (a_pin(l, "p") - 1))
    assert abs((pads[0] + pads[1]) / 2 - xa) < 1e-6, (pr.name, pads, xa)
    c = min(0.3, abs(xa - xg) / 2)
    sgn = 1 if xa > xg else -1
    pr.add(xg, Y_STRIP + c, lay)
    pr.add(xg + sgn * c, Y_STRIP, lay)
    pr.add(xa - sgn * c, Y_STRIP, lay)
    pr.add(xa, Y_STRIP - c, lay)
    pr.add(xa, Y_AROW + 0.42, lay)
    pr.end = ((pads[0], Y_AROW), (pads[1], Y_AROW), lay, True)


def b_pin(l, pol):
    return {0: {"p": 3, "n": 4}, 1: {"p": 6, "n": 7}, 2: {"p": 15, "n": 16}, 3: {"p": 18, "n": 19}}[l][pol]


def a_pin(l, pol):
    return {0: {"p": 3, "n": 4}, 1: {"p": 6, "n": 7}, 2: {"p": 15, "n": 16}, 3: {"p": 18, "n": 19}}[l][pol]


TUNE = {}   # pair name -> centreline points inserted in band M (filled by tune())

# band-M tuning windows and direction (away from same-layer neighbours)
TUNE_DIR = {("P", 0): -1, ("R", 0): +1, ("R", 1): -1, ("P", 1): +1,
            ("R", 3): -1, ("P", 3): +1, ("P", 2): -1, ("R", 2): +1}
# y windows (top, bottom) so that P3 (east bumps) and P2 (west bumps) never bulge at the same height
TUNE_WIN = {("P", 0): (56.0, 38.0), ("R", 0): (56.0, 38.0), ("R", 1): (77.0, 58.0), ("P", 1): (77.0, 58.0),
            ("R", 3): (77.0, 58.0), ("P", 3): (77.0, 67.6), ("P", 2): (67.0, 58.0), ("R", 2): (77.0, 58.0)}


def accordion(x0, ytop, side, amp, n, g=1.1, c=0.4):
    """Centreline points of n accordion periods heading north from (x0, ytop) (y decreasing)."""
    pts = []
    y = ytop
    x1 = x0 + side * amp
    for i in range(n):
        y1 = y; y2 = y - g; y3 = y - 2 * g
        pts += [(x0, y1 + c), (x0 + side * c, y1), (x1 - side * c, y1), (x1, y1 - c),
                (x1, y2 + c), (x1 - side * c, y2), (x0 + side * c, y2), (x0, y2 - c)]
        y = y3
    return pts


def bumps(x0, ytop, side, amp, n, flat=0.4, gap=0.5):
    pts = []
    y = ytop
    for i in range(n):
        pts += [(x0, y), (x0 + side * amp, y - amp), (x0 + side * amp, y - amp - flat), (x0, y - 2 * amp - flat)]
        y = y - 2 * amp - flat - gap
    return pts


def tune(iterations=4, tol_ps=0.3):
    """Delay-match each port's TX lanes and RX lanes (by ps, including via barrels)."""
    import delay
    TUNE.clear()
    for it in range(iterations):
        pairs = [build(pp) for k in range(4) for l in range(4) for pp in plan_lane(k, l)]
        byname = {pp.name: pp for pp in pairs}
        changed = False
        for k in range(4):
            for kind in "PR":
                grp = [byname[f"{kind}{k}{l}"] for l in range(4)]
                d = {pp.name: delay.pair_delay(pp) for pp in grp}
                tgt = max(d.values())
                for pp in grp:
                    l = int(pp.name[2])
                    need_ps = tgt - d[pp.name]
                    if need_ps < tol_ps:
                        continue
                    lay = "L5" if l == 3 else LAYER_S[l]
                    need_mm = need_ps / delay.per_mm(lay)
                    cur = TUNE.get(pp.name)
                    xm = CX[k] + S_EAST + xm_rel(kind, l)
                    ytop, ybot = TUNE_WIN[(kind, l)]
                    side = TUNE_DIR[(kind, l)]
                    # current extra length of the existing tuning, then add the shortfall
                    have = getattr(tune, "extra", {}).get(pp.name, 0.0)
                    want = have + need_mm
                    if l == 0:
                        n = max(1, math.ceil(want / 4.6))
                        amp = want / (2 * n) + 0.234   # chamfer correction: 4 corners x 0.586c / (2 per period)
                        pts = accordion(xm, ytop, side, amp, n)
                    else:
                        n = max(1, math.ceil(want / 0.45))
                        amp = want / (0.828 * n)
                        pts = bumps(xm, ytop, side, amp, n)
                    if pts[-1][1] < ybot:
                        raise SystemExit(f"tuning for {pp.name} overruns its window: {pts[-1][1]:.1f} < {ybot}")
                    TUNE[pp.name] = pts
                    tune.extra = getattr(tune, "extra", {})
                    tune.extra[pp.name] = want
                    changed = True
        if not changed:
            break
    return TUNE


def all_lanes():
    out = []
    for k in range(4):
        for l in range(4):
            P, R = plan_lane(k, l)
            out += [build(P), build(R)]
    return out


# ------------------------------------------------------------------------------------------ refclks and the band-M bus
Y_BUS = {"PERST1": 80.0, "REFCLK2": 81.5, "PERST2": 83.0, "REFCLK3": 84.5, "PERST3": 86.0, "REFCLK1": 87.5}
X_CORR_VIA = lambda k: CX[k] + S_EAST + 0.025          # in the R0-R3 gap of corridor k (band M)
G_CONT, G_PERST, G_REF = 12.35, 13.15, 14.4            # positions in gap (k-1, k), relative to cx[k-1] (band M)
Y_REF_TURN, Y_PERST_TURN = 12.3, 10.9
BUS_START_X = 16.0                                      # PERST bus lines start here (L1 -> L5 via)


def corner_path(pts_xy, layer, c=0.3):
    """Expand a rectilinear list of points into chamfered octilinear nodes (45 degree chamfer c)."""
    out = [pts_xy[0]]
    for i in range(1, len(pts_xy) - 1):
        a, b, cc = pts_xy[i - 1], pts_xy[i], pts_xy[i + 1]
        d1 = unit(b[0] - a[0], b[1] - a[1]); d2 = unit(cc[0] - b[0], cc[1] - b[1])
        if abs(d1[0] * d2[1] - d1[1] * d2[0]) < 1e-9:
            out.append(b); continue
        out.append((b[0] - d1[0] * c, b[1] - d1[1] * c))
        out.append((b[0] + d2[0] * c, b[1] + d2[1] * c))
    out.append(pts_xy[-1])
    return out


def bus_route(k_from, k_to, y, first_layer="L5"):
    """Waypoint list (x, y, layer) for a bus line that starts west of corridor k_from on L5 and exits into
    gap (k_to - 1, k_to). Each corridor: L5 under its west half, a via in the R0-R3 gap, L3 over its east half;
    each gap passed through: back to L5."""
    nodes = []
    for k in range(k_from, k_to):
        xv = X_CORR_VIA(k)
        nodes.append((xv, y, "L5"))
        nodes.append((xv, y, "L3"))
        if k < k_to - 1:
            xg = CX[k] + G_CONT
            nodes.append((xg, y, "L3"))
            nodes.append((xg, y, "L5"))
    return nodes


def refclk(k):
    """REFCLK k from its gold fingers to OCuLink port k (A12 p / A13 n)."""
    P = Pair(f"CLK{k}", f"REFCLKn{k}", f"REFCLKp{k}", "REFCLK")
    if k in (0, 1):
        pins = ("B14", "B15") if k == 0 else ("A14", "A15")
    else:
        pins = ("OCP_B11", "OCP_B12") if k == 2 else ("OCP_A11", "OCP_A12")
    lay0 = "L1" if pins[0].startswith(("B", "OCP_B")) else "L6"
    P.start = (finger(pins[0]), finger(pins[1]), lay0)
    xc_f = F + PX[pins[0]] + 0.3
    y0 = PAD_TOP - 0.25 - (0.6 - pitch(lay0)) / 2
    P.add(xc_f, y0, lay0)
    cx = CX[k]
    if k == 0:
        P.add(xc_f, 107.9, "L1"); P.add(19.1, 107.9 - (xc_f - 19.1), "L1"); P.add(19.1, 97.0, "L1")
        P.add(19.1, 97.0, "L3"); P.add(19.1, 96.3, "L3"); P.add(3.6, 96.3 - 15.5, "L3")
        for (x, y) in corner_path([(3.6, 80.8), (3.6, Y_REF_TURN), (cx - 0.5, Y_REF_TURN), (cx - 0.5, Y_REF_TURN - 0.8)], "L3")[1:]:
            P.add(x, y, "L3")
        lay = "L3"
    else:
        yb = Y_BUS[f"REFCLK{k}"]
        if k == 1:
            P.add(xc_f, 108.4, "L6"); P.add(17.6, 108.4 - (xc_f - 17.6), "L6"); P.add(17.6, 98.0, "L6")
            P.add(17.6, 98.0, "L5"); xs = 17.6
        elif k == 2:
            P.add(xc_f, 107.9, "L1"); P.add(12.6, 107.9 - (xc_f - 12.6), "L1"); P.add(12.6, 106.5, "L1")
            P.add(12.6, 106.5, "L5"); xs = 12.6
        else:
            P.add(xc_f, 107.9, "L6"); P.add(14.6, 107.9 - (14.6 - xc_f), "L6"); P.add(14.6, 106.5, "L6")
            P.add(14.6, 106.5, "L5"); xs = 14.6
        for (x, y) in corner_path([(xs, 106.0 if k > 1 else 97.6), (xs, yb), (X_CORR_VIA(0), yb)], "L5")[1:]:
            P.add(x, y, "L5")
        for nd in bus_route(0, k, yb)[1:]:
            P.add(*nd)
        g = CX[k - 1] + G_REF
        lay = "L3"
        pts = corner_path([(P.nodes[-1][0], yb), (g, yb), (g, Y_M2)], "L3")
        for (x, y) in pts[1:]:
            P.add(x, y, lay)
        P.add(g - S_EAST, Y_M2 - S_EAST, lay)
        for (x, y) in corner_path([(g - S_EAST, Y_M2 - S_EAST), (g - S_EAST, Y_REF_TURN), (cx - 0.5, Y_REF_TURN), (cx - 0.5, Y_REF_TURN - 0.8)], lay)[1:]:
            P.add(x, y, lay)
    # B-row gap B11/B14, strip, A12/A13
    xg, xa = cx - 0.5, cx - 1.0
    c = 0.25
    P.add(xg, Y_STRIP + c, lay); P.add(xg - c, Y_STRIP, lay); P.add(xa + c, Y_STRIP, lay); P.add(xa, Y_STRIP - c, lay)
    P.add(xa, Y_AROW + 0.42, lay)
    P.end = ((cx + 4.75 - 0.5 * 12, Y_AROW), (cx + 4.75 - 0.5 * 11, Y_AROW), lay, True)
    return build(P)


class Line:
    """A single-ended route: nodes (x, y, layer); a layer change places one via."""
    def __init__(self, name, net, w=0.15):
        self.name, self.net, self.w = name, net, w
        self.nodes, self.tracks, self.vias = [], [], []

    def add(self, x, y, layer):
        self.nodes.append((round(x, 4), round(y, 4), layer)); return self

    def build(self):
        for a, b in zip(self.nodes, self.nodes[1:]):
            if a[2] != b[2]:
                assert abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6
                self.vias.append((a[0], a[1], self.net, VIA_D, VIA_H))
            elif math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6:
                self.tracks.append((a[2], self.net, a[0], a[1], b[0], b[1], self.w))
        return self


def perst_bus(k):
    """PORTk_PERST# from its bus start via (fed from U4 through R1x by the maze router) to the B12 pad of port k."""
    L = Line(f"PERST{k}", f"PORT{k}_PERST#")
    y = Y_BUS[f"PERST{k}"]
    cx = CX[k]
    L.add(BUS_START_X, y, "L1"); L.add(BUS_START_X, y, "L5")
    for nd in bus_route(0, k, y):
        L.add(*nd)
    g = CX[k - 1] + G_PERST
    L.add(g, y, "L3"); L.add(g, y, "L1")
    for (x, yy) in corner_path([(g, y), (g, Y_M2), (g - S_EAST, Y_M2 - S_EAST), (g - S_EAST, Y_PERST_TURN),
                                (cx - 0.25, Y_PERST_TURN), (cx - 0.25, Y_BROW)], "L1", c=0.3)[1:]:
        L.add(x, yy, "L1")
    return L.build()


def all_routes():
    tune()
    pairs = all_lanes() + [refclk(k) for k in range(4)]
    lines = [perst_bus(k) for k in (1, 2, 3)]
    return pairs, lines


if __name__ == "__main__":
    pairs = all_lanes()
    print(len(pairs), "pairs", sum(len(p.tracks) for p in pairs), "tracks", sum(len(p.vias) for p in pairs), "vias")