pixalynx/nrf9151-gps-tracker

A compact 44×32 mm, 4-layer nRF9151 LTE-M/NB-IoT/GNSS tracker PCB with nPM1300 LiPo/USB-C power, nano-SIM/eSIM switching, nRF7002 Wi‑Fi scanning, accelerometer, antennas, buzzer, LEDs, SOS button, and SWD/debug connectors.

Version
0.1.4
License
unset
Stars
0

scripts/hand-route-net.py

#!/usr/bin/env python3
"""Grid-search hand router for the handful of two-land connections Freerouting would not make after six
attempts. Routes the middle of the connection on inner2 (BFS on a 0.1 mm grid, 8-directional), and finds a clear
via spot near each land by the same directional search gen-gnd-mesh.py uses for ground stubs.

Usage: python3 scripts/hand-route-net.py index <fromRef> <toRef> [--mid inner2] [--width 0.12]
       python3 scripts/hand-route-net.py index <fromRef> <toRef> --maze [--width 0.1] [--via-cost 2] [--layers top,inner2,bottom]   (multi-layer A*)
       python3 scripts/hand-route-net.py index <groundRef> --stub   (clear via into the inner1 GND plane next to the land)
Prints a JSX <trace> ready to paste into tracker.circuit.tsx (uses localPath / the from-land's component frame).
"""
import json, sys, math, heapq
from collections import deque

board_key, from_ref, to_ref = sys.argv[1], sys.argv[2], sys.argv[3]
MIDLAYER = sys.argv[sys.argv.index('--mid') + 1] if '--mid' in sys.argv else 'inner2'
RF_EXCLUDE_Y = -99.0  # pixal-gps kept its RF chain out of reach; not needed here (inner2 sits under the L2 plane)
RES = 0.1           # grid pitch, mm
TRACE_W = float(sys.argv[sys.argv.index('--width') + 1]) if '--width' in sys.argv else 0.12
CLEAR = 0.12
VIA_D, VIA_HOLE = 0.4, 0.2

c = json.load(open(f'dist/{board_key}/circuit.json'))
board = next(e for e in c if e['type'] == 'pcb_board')
BX0, BY0 = board['center']['x'] - board['width'] / 2, board['center']['y'] - board['height'] / 2
BX1, BY1 = board['center']['x'] + board['width'] / 2, board['center']['y'] + board['height'] / 2
sc = {e['source_component_id']: e['name'] for e in c if e['type'] == 'source_component'}
sports = {p['source_port_id']: p for p in c if p['type'] == 'source_port'}
ports = {p['pcb_port_id']: p for p in c if p['type'] == 'pcb_port'}
pcb_comps = {e['pcb_component_id']: e for e in c if e['type'] == 'pcb_component'}
def net_of(pcb_port_id):
    p = ports.get(pcb_port_id); sp = sports.get(p['source_port_id']) if p else None
    return sp.get('subcircuit_connectivity_map_key') if sp else None
def sel_of(pcb_port_id):
    p = ports.get(pcb_port_id); sp = sports.get(p['source_port_id']) if p else None
    return f"{sc.get(sp['source_component_id'])}.{sp.get('name')}" if sp else None

lands = []  # (layers:set, kind, geom, net, ref, comp_id)
for e in c:
    if e['type'] == 'pcb_smtpad':
        g = ('circle', e['x'], e['y'], e['radius']) if e['shape'] == 'circle' else ('rect', e['x'], e['y'], e['width'], e['height'], e.get('ccw_rotation') or 0)
        lands.append(({e['layer']}, 'land', g, net_of(e.get('pcb_port_id')), sel_of(e.get('pcb_port_id')), e.get('pcb_component_id')))
    elif e['type'] == 'pcb_plated_hole':
        d = e.get('outer_diameter') or max(e.get('outer_width', 0), e.get('outer_height', 0))
        lands.append(({'top', 'inner1', 'inner2', 'bottom'}, 'land', ('circle', e['x'], e['y'], d / 2), net_of(e.get('pcb_port_id')), sel_of(e.get('pcb_port_id')), None))
    elif e['type'] == 'pcb_hole':
        d = e.get('hole_diameter') or max(e.get('hole_width', 0), e.get('hole_height', 0))
        lands.append(({'top', 'inner1', 'inner2', 'bottom'}, 'hole', ('circle', e['x'], e['y'], d / 2), 'HOLE', 'hole', None))
st = {e['source_trace_id']: e for e in c if e['type'] == 'source_trace'}
segs = []  # (layer, a, b, width, net)
vias = []  # (x, y, dia, net)
for t in c:
    if t['type'] != 'pcb_trace': continue
    net = st.get(t.get('source_trace_id'), {}).get('subcircuit_connectivity_map_key')
    for p in t['route']:
        if p['route_type'] == 'via': vias.append((p['x'], p['y'], p.get('via_diameter') or VIA_D, net))
    for a, b in zip(t['route'], t['route'][1:]):  # wire->via and via->wire pairs are copper too
        layer = a.get('layer') if a['route_type'] == 'wire' else b.get('layer') if b['route_type'] == 'wire' else None
        if layer: segs.append((layer, (a['x'], a['y']), (b['x'], b['y']), (a.get('width') if a['route_type'] == 'wire' else None) or b.get('width') or TRACE_W, net))
for v in c:
    if v['type'] == 'pcb_via' and not any(abs(v['x'] - x) < 1e-6 and abs(v['y'] - y) < 1e-6 for x, y, *_ in vias):
        vias.append((v['x'], v['y'], v.get('outer_diameter') or VIA_D, None))

def pt_land(px, py, g):
    if g[0] == 'circle': return max(0.0, math.hypot(px - g[1], py - g[2]) - g[3])
    _, x, y, w, h, rot = g; r = -math.radians(rot); dx, dy = px - x, py - y
    rx, ry = dx * math.cos(r) - dy * math.sin(r), dx * math.sin(r) + dy * math.cos(r)
    return math.hypot(max(abs(rx) - w / 2, 0), max(abs(ry) - h / 2, 0))
def seg_pt(px, py, a, b):
    vx, vy = b[0] - a[0], b[1] - a[1]; l2 = vx * vx + vy * vy
    t = 0 if l2 == 0 else max(0, min(1, ((px - a[0]) * vx + (py - a[1]) * vy) / l2))
    return math.hypot(px - (a[0] + t * vx), py - (a[1] + t * vy))

def clear_at_point(px, py, layer, my_net, my_half):
    """min clearance (mm, can be negative) from (px,py) with half-width my_half on `layer`, ignoring same-net copper."""
    best = 1e9
    for ls, kind, g, net, ref, _ in lands:
        if layer not in ls or net == my_net: continue
        best = min(best, pt_land(px, py, g) - my_half)
        if best < -1: return best
    for lyr, a, b, w, net in segs:
        if lyr != layer or (net == my_net and net is not None): continue
        best = min(best, seg_pt(px, py, a, b) - w / 2 - my_half)
        if best < -1: return best
    for x, y, dia, net in vias:
        if net == my_net and net is not None: continue
        best = min(best, math.hypot(px - x, py - y) - dia / 2 - my_half)
        if best < -1: return best
    best = min(best, px - BX0 - 0.3 - my_half, BX1 - px - 0.3 - my_half, py - BY0 - 0.3 - my_half, BY1 - py - 0.3 - my_half)
    return best

ALL_LAYERS = ['top', 'inner1', 'inner2', 'bottom']
def land_half(l):
    return ((l.get('width') or 2 * l.get('radius', 0.2)) / 2, (l.get('height') or 2 * l.get('radius', 0.2)) / 2)
def find_via_near(px, py, home_layer, net, max_r=5.0, r0=0.2):
    """Search outward for a spot where a through via (it spans every layer physically, whichever two carry
    copper) clears everything of a different net on ALL FOUR layers, not just the two the net uses, and the straight
    hop from the land centre to it clears other-net copper on the land's own layer."""
    best = None
    for r in [x * 0.05 for x in range(int(r0 / 0.05), int(max_r / 0.05))]:
        for k in range(48):
            a = 2 * math.pi * k / 48
            x, y = px + r * math.cos(a), py + r * math.sin(a)
            if min(clear_at_point(x, y, l, net, VIA_D / 2) for l in ALL_LAYERS) >= CLEAR and \
               min(clear_at_point(px + (x - px) * i / 10, py + (y - py) * i / 10, home_layer, net, TRACE_W / 2) for i in range(11)) >= CLEAR:
                d = math.hypot(x - px, y - py)
                if best is None or d < best[0]: best = (d, x, y)
        if best: return best[1], best[2]
    return None

def route_inner2(p0, p1, net, layer=MIDLAYER, margin=3.0):
    # Try a padded box around the two points first (a short, sane route almost always lives there);
    # only widen toward the whole board if that box genuinely has no path, so a contested local via
    # doesn't produce a route that wanders across the entire board to reach a technically-open cell.
    x0 = max(BX0 + 0.5, min(p0[0], p1[0]) - margin)
    x1 = min(BX1 - 0.5, max(p0[0], p1[0]) + margin)
    y0 = max(BY0 + 0.5, min(p0[1], p1[1]) - margin)
    y1 = min(BY1 - 0.5, max(p0[1], p1[1]) + margin)
    nx, ny = int((x1 - x0) / RES) + 1, int((y1 - y0) / RES) + 1
    def cell(x, y): return (round((x - x0) / RES), round((y - y0) / RES))
    def pt(i, j): return (x0 + i * RES, y0 + j * RES)
    blocked = [[False] * ny for _ in range(nx)]
    # RF exclusion: the LTE/GNSS chains and their 0.5 mm keepout corridor live below y=-5.5; none of the
    # hand-routed non-RF nets need that area, and a wandering BFS detour there is not worth the RF risk.
    rf_y = min(RF_EXCLUDE_Y, max(y0, min(y1, RF_EXCLUDE_Y)))
    j_rf = cell(0, rf_y)[1]
    if 0 <= j_rf < ny:
        for i in range(nx):
            for j in range(0, j_rf + 1): blocked[i][j] = True
    half = TRACE_W / 2 + CLEAR
    for ls, kind, g, gnet, ref, _ in lands:
        if layer not in ls or gnet == net: continue
        gx, gy = g[1], g[2]; pad = (g[3] if g[0] == 'circle' else max(g[3], g[4]) / 2) + half
        i0, i1 = max(0, cell(gx - pad, 0)[0]), min(nx - 1, cell(gx + pad, 0)[0])
        j0, j1 = max(0, cell(0, gy - pad)[1]), min(ny - 1, cell(0, gy + pad)[1])
        for i in range(i0, i1 + 1):
            for j in range(j0, j1 + 1):
                px, py = pt(i, j)
                if pt_land(px, py, g) < half: blocked[i][j] = True
    for lyr, a, b, w, gnet in segs:
        if lyr != layer or (gnet == net and gnet is not None): continue
        pad = w / 2 + half
        i0, i1 = max(0, cell(min(a[0], b[0]) - pad, 0)[0]), min(nx - 1, cell(max(a[0], b[0]) + pad, 0)[0])
        j0, j1 = max(0, cell(0, min(a[1], b[1]) - pad)[1]), min(ny - 1, cell(0, max(a[1], b[1]) + pad)[1])
        for i in range(i0, i1 + 1):
            for j in range(j0, j1 + 1):
                px, py = pt(i, j)
                if seg_pt(px, py, a, b) < pad: blocked[i][j] = True
    for vx, vy, dia, gnet in vias:
        if gnet == net and gnet is not None: continue
        pad = dia / 2 + half
        i0, i1 = max(0, cell(vx - pad, 0)[0]), min(nx - 1, cell(vx + pad, 0)[0])
        j0, j1 = max(0, cell(0, vy - pad)[1]), min(ny - 1, cell(0, vy + pad)[1])
        for i in range(i0, i1 + 1):
            for j in range(j0, j1 + 1):
                px, py = pt(i, j)
                if math.hypot(px - vx, py - vy) < pad: blocked[i][j] = True
    s, g = cell(*p0), cell(*p1)
    s = (min(max(s[0], 0), nx - 1), min(max(s[1], 0), ny - 1)); g = (min(max(g[0], 0), nx - 1), min(max(g[1], 0), ny - 1))
    dirs = [(1, 0, 1), (-1, 0, 1), (0, 1, 1), (0, -1, 1), (1, 1, 1.4142), (1, -1, 1.4142), (-1, 1, 1.4142), (-1, -1, 1.4142)]
    dist = {s: 0.0}; prev = {}; heap = [(0.0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if u == g: break
        if d > dist.get(u, 1e18): continue
        for dx, dy, w in dirs:
            v = (u[0] + dx, u[1] + dy)
            if not (0 <= v[0] < nx and 0 <= v[1] < ny) or blocked[v[0]][v[1]]: continue
            nd = d + w
            if nd < dist.get(v, 1e18): dist[v] = nd; prev[v] = u; heapq.heappush(heap, (nd, v))
    if g not in prev and g != s: return None
    path, u = [g], g
    while u != s: u = prev[u]; path.append(u)
    path.reverse()
    pts = [pt(*p) for p in path]
    # simplify: keep only points where direction changes
    simp = [pts[0]]
    for i in range(1, len(pts) - 1):
        a, b, cpt = simp[-1], pts[i], pts[i + 1]
        if abs((b[0] - a[0]) * (cpt[1] - a[1]) - (b[1] - a[1]) * (cpt[0] - a[0])) > 1e-9: simp.append(b)
    simp.append(pts[-1])
    return simp

def seg_clear(a, b, layer, net):
    n = max(2, int(math.hypot(b[0] - a[0], b[1] - a[1]) / 0.1))
    return min(clear_at_point(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n, layer, net, TRACE_W / 2) for i in range(n + 1))
def smooth(pts, layer, net):
    """string-pull the grid staircase: from each kept point jump to the farthest later point in clear line of sight"""
    out, i = [pts[0]], 0
    while i < len(pts) - 1:
        j = len(pts) - 1
        while j > i + 1 and seg_clear(pts[i], pts[j], layer, net) < CLEAR: j -= 1
        out.append(pts[j]); i = j
    return out

pm = {}
for e in c:
    if e['type'] not in ('pcb_smtpad', 'pcb_plated_hole'): continue
    port = ports.get(e.get('pcb_port_id')); sp = sports.get(port['source_port_id']) if port else None
    if not sp: continue
    ref = f"{sc.get(sp['source_component_id'])}.{sp.get('name')}"
    pm[ref] = e

if to_ref == '--stub':
    fa = pm[from_ref]; fnet = net_of(fa.get('pcb_port_id'))
    hw = (fa.get('width') or 2 * fa.get('radius', 0.2)) / 2; hh = (fa.get('height') or 2 * fa.get('radius', 0.2)) / 2
    v = find_via_near(fa['x'], fa['y'], fa['layer'], fnet, max_r=2.5, r0=math.hypot(hw, hh) * 0.8 + 0.3)  # keep the via off its own land
    if not v: print('NO CLEAR VIA SPOT', from_ref); sys.exit(1)
    # the stub itself must clear other-net copper on the land's layer
    n = 12
    worst = min(clear_at_point(fa['x'] + (v[0] - fa['x']) * i / n, fa['y'] + (v[1] - fa['y']) * i / n, fa['layer'], fnet, TRACE_W / 2) for i in range(n + 1))
    comp = pcb_comps[fa['pcb_component_id']]; ccx, ccy, crot = comp['center']['x'], comp['center']['y'], comp.get('rotation') or 0
    dx, dy = v[0] - ccx, v[1] - ccy; a = -math.radians(crot)
    lx, ly = round(dx * math.cos(a) - dy * math.sin(a), 4), round(dx * math.sin(a) + dy * math.cos(a), 4)
    print(f'# stub clearance {worst:.3f} mm, via at ({v[0]:.3f},{v[1]:.3f})')
    print(f'<trace name="gstub_{from_ref.replace(".", "_")}" from="{from_ref}" to="net.GND" thickness={{{TRACE_W}}} pcbPath={{[{{ x: {lx}, y: {ly} }}, {{ x: {lx}, y: {ly}, via: true, toLayer: "inner1" }}]}} />')
    sys.exit(0 if worst >= CLEAR - 1e-6 else 2)
if '--maze' in sys.argv:
    # multi-layer maze route (top / inner2 / bottom; inner1 is the GND plane): A* on the 0.1 mm grid with through vias
    # that must clear every layer, the RF corridors of the DSN export, then per-layer string pulling with an exact check
    fa, ta = pm[from_ref], pm[to_ref]; fnet = net_of(fa.get('pcb_port_id'))
    RL = sys.argv[sys.argv.index('--layers') + 1].split(',') if '--layers' in sys.argv else ['top', 'inner2', 'bottom']; VIA_COST = float(sys.argv[sys.argv.index('--via-cost') + 1]) if '--via-cost' in sys.argv else 2.0
    RF_HALF, GRID_CLEAR, EXACT_CLEAR = 0.5, 0.13, 0.105
    nx, ny = int((BX1 - BX0) / RES) + 1, int((BY1 - BY0) / RES) + 1
    tblock = {l: bytearray(nx * ny) for l in RL}; vblock = bytearray(nx * ny)
    def stamp(grid, dist_fn, cx0, cx1, cy0, cy1, limit):
        i0, i1 = max(0, int((cx0 - BX0) / RES)), min(nx - 1, int(math.ceil((cx1 - BX0) / RES)))
        j0, j1 = max(0, int((cy0 - BY0) / RES)), min(ny - 1, int(math.ceil((cy1 - BY0) / RES)))
        for i in range(i0, i1 + 1):
            x = BX0 + i * RES; base = i * ny
            for j in range(j0, j1 + 1):
                if not grid[base + j] and dist_fn(x, BY0 + j * RES) < limit: grid[base + j] = 1
    tw, vw = TRACE_W / 2 + GRID_CLEAR, VIA_D / 2 + GRID_CLEAR
    for ls, kind, g, gnet, ref, _ in lands:
        if gnet == fnet and gnet is not None:  # own-net land: copper may run in, a via may not sit in it (solder wicking)
            if len(ls) == 1:
                r = (g[3] if g[0] == 'circle' else math.hypot(g[3], g[4]) / 2) + VIA_D
                stamp(vblock, lambda x, y, g=g: pt_land(x, y, g), g[1] - r, g[1] + r, g[2] - r, g[2] + r, VIA_D / 2 + 0.05)
            continue
        extra = 0.3 if kind == 'hole' else 0.0
        r = (g[3] if g[0] == 'circle' else math.hypot(g[3], g[4]) / 2) + vw + extra
        f = lambda x, y, g=g: pt_land(x, y, g)
        for l in RL:
            if l in ls: stamp(tblock[l], f, g[1] - r, g[1] + r, g[2] - r, g[2] + r, tw + extra)
        stamp(vblock, f, g[1] - r, g[1] + r, g[2] - r, g[2] + r, vw + extra)
    for lyr, a, b, w, gnet in segs:
        if gnet == fnet and gnet is not None: continue
        r = w / 2 + vw; f = lambda x, y, a=a, b=b: seg_pt(x, y, a, b)
        box = (min(a[0], b[0]) - r, max(a[0], b[0]) + r, min(a[1], b[1]) - r, max(a[1], b[1]) + r)
        if lyr in tblock: stamp(tblock[lyr], f, *box, w / 2 + tw)
        stamp(vblock, f, *box, w / 2 + vw)
    for x, y, dia, gnet in vias:
        if gnet == fnet and gnet is not None:  # own-net via: copper may touch it, but a new drill keeps 0.2 mm off its hole
            f = lambda px, py, x=x, y=y: math.hypot(px - x, py - y)
            stamp(vblock, f, x - 0.5, x + 0.5, y - 0.5, y + 0.5, VIA_HOLE + 0.2 + 0.02); continue
        r = dia / 2 + vw; f = lambda px, py, x=x, y=y: math.hypot(px - x, py - y)
        for l in RL: stamp(tblock[l], f, x - r, x + r, y - r, y + r, dia / 2 + tw)
        stamp(vblock, f, x - r, x + r, y - r, y + r, dia / 2 + vw)
    rf = []
    for t in c:
        if t['type'] != 'pcb_trace' or not (st.get(t.get('source_trace_id'), {}).get('name') or '').startswith('rf_'): continue
        for a, b in zip(t['route'], t['route'][1:]):
            layer = a.get('layer') if a['route_type'] == 'wire' else b.get('layer') if b['route_type'] == 'wire' else None
            if layer == 'top': rf.append(((a['x'], a['y']), (b['x'], b['y'])))
    for a, b in rf:
        r = RF_HALF + vw; f = lambda x, y, a=a, b=b: seg_pt(x, y, a, b)
        box = (min(a[0], b[0]) - r, max(a[0], b[0]) + r, min(a[1], b[1]) - r, max(a[1], b[1]) + r)
        stamp(tblock['top'], f, *box, RF_HALF + TRACE_W / 2)
        stamp(vblock, f, *box, RF_HALF + VIA_D / 2)
    for i in range(nx):
        x = BX0 + i * RES
        for j in range(ny):
            e = min(x - BX0, BX1 - x, BY0 + j * RES - BY0, BY1 - BY0 - j * RES)
            if e < 0.3 + TRACE_W / 2:
                for l in RL: tblock[l][i * ny + j] = 1
            if e < 0.3 + VIA_D / 2: vblock[i * ny + j] = 1
    def cell(x, y): return min(nx - 1, max(0, round((x - BX0) / RES))), min(ny - 1, max(0, round((y - BY0) / RES)))
    reuse = {}  # an existing via of this net can be used for a layer change as it is (no new drill)
    for x, y, dia, gnet in vias:
        if gnet == fnet and gnet is not None:
            ci, cj = cell(x, y); reuse[(ci, cj)] = (x, y); vblock[ci * ny + cj] = 0
            for l in RL: tblock[l][ci * ny + cj] = 0
    def land_layers(e): return [l for l in RL if l == e.get('layer')] if e['type'] == 'pcb_smtpad' else RL
    si, sj = cell(fa['x'], fa['y']); gi, gj = cell(ta['x'], ta['y'])
    starts = [(RL.index(l), si, sj) for l in land_layers(fa)]; goals = {(RL.index(l), gi, gj) for l in land_layers(ta)}
    for k, i, j in starts + list(goals): tblock[RL[k]][i * ny + j] = 0
    h = lambda i, j: math.hypot(i - gi, j - gj) * RES
    dist, prev, heap = {}, {}, []
    for n in starts: dist[n] = 0.0; heapq.heappush(heap, (h(n[1], n[2]), 0.0, n))
    dirs = [(1, 0, RES), (-1, 0, RES), (0, 1, RES), (0, -1, RES)] + [(a, b, RES * 1.41421) for a in (1, -1) for b in (1, -1)]
    found = None
    while heap:
        _, d, u = heapq.heappop(heap)
        if d > dist.get(u, 1e18): continue
        if u in goals: found = u; break
        k, i, j = u
        for di, dj, w in dirs:
            vi, vj = i + di, j + dj
            if 0 <= vi < nx and 0 <= vj < ny and not tblock[RL[k]][vi * ny + vj]:
                v = (k, vi, vj); nd = d + w
                if nd < dist.get(v, 1e18): dist[v] = nd; prev[v] = u; heapq.heappush(heap, (nd + h(vi, vj), nd, v))
        if not vblock[i * ny + j]:
            for k2 in range(len(RL)):
                if k2 != k and not tblock[RL[k2]][i * ny + j]:
                    v = (k2, i, j); nd = d + VIA_COST
                    if nd < dist.get(v, 1e18): dist[v] = nd; prev[v] = u; heapq.heappush(heap, (nd + h(i, j), nd, v))
    if not found: print('NO MAZE PATH', from_ref, '->', to_ref); sys.exit(1)
    path, u = [found], found
    while u in prev: u = prev[u]; path.append(u)
    path.reverse()
    def rf_ok(x, y, half): return all(seg_pt(x, y, a, b) >= RF_HALF + half for a, b in rf)
    def seg_ok(a, b, layer):
        n = max(2, int(math.hypot(b[0] - a[0], b[1] - a[1]) / 0.02))
        for q in range(n + 1):
            x, y = a[0] + (b[0] - a[0]) * q / n, a[1] + (b[1] - a[1]) * q / n
            if clear_at_point(x, y, layer, fnet, TRACE_W / 2) < EXACT_CLEAR: return False
            if layer == 'top' and not rf_ok(x, y, TRACE_W / 2): return False
        return True
    runs = []  # [(layer, [pts])]
    for k, i, j in path:
        p_ = reuse.get((i, j)) or (BX0 + i * RES, BY0 + j * RES)
        if runs and runs[-1][0] == RL[k]: runs[-1][1].append(p_)
        else: runs.append((RL[k], [p_]))
    runs[0][1][0] = (fa['x'], fa['y']); runs[-1][1][-1] = (ta['x'], ta['y'])
    board_pts, worst = [], []
    for ri, (layer, pts) in enumerate(runs):
        out, i = [pts[0]], 0
        while i < len(pts) - 1:
            j = len(pts) - 1
            while j > i + 1 and not seg_ok(pts[i], pts[j], layer): j -= 1
            if not seg_ok(pts[i], pts[j], layer): worst.append((layer, pts[i], pts[j]))
            out.append(pts[j]); i = j
        if ri > 0: board_pts.append({'x': out[0][0], 'y': out[0][1], 'via': True, 'toLayer': layer})
        for q in (out[1:] if ri == 0 else out[1:]): board_pts.append({'x': q[0], 'y': q[1]})
    for v in (p_ for p_ in board_pts if p_.get('via')):
        vc = min(clear_at_point(v['x'], v['y'], l, fnet, VIA_D / 2) for l in ALL_LAYERS)
        if vc < EXACT_CLEAR or not rf_ok(v['x'], v['y'], VIA_D / 2): worst.append(('via', (v['x'], v['y']), vc))
    comp = pcb_comps[fa['pcb_component_id']]; ccx, ccy, crot = comp['center']['x'], comp['center']['y'], comp.get('rotation') or 0
    def to_local(px, py):
        dx, dy = px - ccx, py - ccy; a = -math.radians(crot)
        return round(dx * math.cos(a) - dy * math.sin(a), 4), round(dx * math.sin(a) + dy * math.cos(a), 4)
    def fmt(d):
        lx, ly = to_local(d['x'], d['y']); s_ = f'{{ x: {lx}, y: {ly}'
        return s_ + (f', via: true, toLayer: "{d["toLayer"]}" }}' if d.get('via') else ' }')
    nv = sum(1 for p_ in board_pts if p_.get('via'))
    L = sum(math.hypot(b['x'] - a['x'], b['y'] - a['y']) for a, b in zip([{'x': fa['x'], 'y': fa['y']}] + board_pts, board_pts))
    print(f'# maze route {from_ref} -> {to_ref}: {L:.1f} mm, {nv} vias, layers {[r[0] for r in runs]}; exact-check failures: {worst or "none"}')
    print(f'<trace name="maze_{from_ref.replace(".", "_")}" from="{from_ref}" to="{to_ref}" thickness={{{TRACE_W}}} pcbPath={{throughVias([' + ', '.join(fmt(d) for d in board_pts) + '])} />')
    sys.exit(0 if not worst else 2)
fa, ta = pm[from_ref], pm[to_ref]
fnet = net_of(fa.get('pcb_port_id'))
flayer = fa['layer']; tlayer = ta['layer']
def route_escalating(p0, p1, net, layer):
    for margin in (3.0, 5.0, 8.0, 13.0, 20.0):
        r = route_inner2(p0, p1, net, layer=layer, margin=margin)
        if r is not None: return r, margin
    return None, None
same_layer_direct = margin_used = None
if flayer == tlayer:
    same_layer_direct, margin_used = route_escalating((fa['x'], fa['y']), (ta['x'], ta['y']), fnet, flayer)
if same_layer_direct is not None:
    via_a = via_b = None
    mid = same_layer_direct
    mid = smooth(mid, flayer, fnet)
    print(f'# direct same-layer ({flayer}) route, no vias, {len(mid)} waypoints, margin {margin_used} mm')
else:
    # a land already on the routing layer needs no via
    via_a = None if flayer == MIDLAYER else find_via_near(fa['x'], fa['y'], flayer, fnet, r0=math.hypot(*land_half(fa)) * 0.8 + 0.3)
    via_b = None if tlayer == MIDLAYER else find_via_near(ta['x'], ta['y'], tlayer, fnet, r0=math.hypot(*land_half(ta)) * 0.8 + 0.3)
    if (flayer != MIDLAYER and not via_a) or (tlayer != MIDLAYER and not via_b): print('NO CLEAR VIA SPOT', from_ref, via_a, to_ref, via_b); sys.exit(1)
    start, goal = via_a or (fa['x'], fa['y']), via_b or (ta['x'], ta['y'])
    mid, margin_used = route_escalating(start, goal, fnet, MIDLAYER)
    if mid is None: print('NO PATH on', MIDLAYER, 'between', start, goal); sys.exit(1)
    mid = smooth(mid, MIDLAYER, fnet)
    print(f'# margin {margin_used} mm')
print(f'# {from_ref} ({fa["x"]:.3f},{fa["y"]:.3f}) {flayer} -> {to_ref} ({ta["x"]:.3f},{ta["y"]:.3f}) {tlayer}, net key ...{(fnet or "")[-6:]}')
print(f'# via A {via_a}, via B {via_b}, {len(mid)} inner2 waypoints')
comp_id = fa.get('pcb_component_id')
comp = pcb_comps[comp_id]; ccx, ccy, crot = comp['center']['x'], comp['center']['y'], comp.get('rotation') or 0
def to_local(px, py):
    dx, dy = px - ccx, py - ccy; a = -math.radians(crot)
    return round(dx * math.cos(a) - dy * math.sin(a), 4), round(dx * math.sin(a) + dy * math.cos(a), 4)
board_pts = []
if same_layer_direct is not None:
    board_pts = [{'x': p[0], 'y': p[1]} for p in mid]
else:
    board_pts.append({'x': fa['x'], 'y': fa['y']})
    if via_a: board_pts.append({'x': via_a[0], 'y': via_a[1], 'via': True, 'toLayer': MIDLAYER})
    for p in mid[1:-1]: board_pts.append({'x': p[0], 'y': p[1]})
    if via_b: board_pts.append({'x': via_b[0], 'y': via_b[1], 'via': True, 'toLayer': tlayer})
    board_pts.append({'x': ta['x'], 'y': ta['y']})
local = []
for p in board_pts:
    lx, ly = to_local(p['x'], p['y'])
    d = {'x': lx, 'y': ly}
    if p.get('via'): d['via'] = True; d['toLayer'] = p['toLayer']
    local.append(d)
def fmt(d):
    s = f'{{ x: {d["x"]}, y: {d["y"]}'
    if d.get('via'): s += f', via: true, toLayer: "{d["toLayer"]}"'
    return s + ' }'
name = f'hand_{from_ref.replace(".", "_")}'
print(f'<trace name="{name}" from="{from_ref}" to="{to_ref}" thickness={{{TRACE_W}}} pcbPath={{[' + ', '.join(fmt(d) for d in local) + f']}} />  {{/* {comp["source_component_id"]} centre ({ccx},{ccy}) rot {crot} */}}')