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} */}}')