pixalynx/pixal-gps
These files define two separate printed circuit boards: a small, two-layer battery cartridge with protection circuitry and contact pads for a pouch cell, and a larger, two-layer wireless charging dock featuring USB-C input, a resonant coil, and wireless power transmission components.
- Version
- 0.1.2
- 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 tracker <fromRef> <toRef> [--layer inner2]
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 = -5.5
RES = 0.1 # grid pitch, mm
TRACE_W = 0.15
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')
prev = None
for p in t['route']:
if p['route_type'] == 'via':
vias.append((p['x'], p['y'], p.get('via_diameter') or VIA_D, net)); prev = None; continue
if prev and prev['layer'] == p['layer']: segs.append((p['layer'], (prev['x'], prev['y']), (p['x'], p['y']), p.get('width', TRACE_W), net))
prev = p
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 find_via_near(px, py, home_layer, net, max_r=5.0):
"""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."""
best = None
for r in [x * 0.1 for x in range(2, int(max_r / 0.1))]:
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:
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
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
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
print(f'# direct same-layer ({flayer}) route, no vias, {len(mid)} waypoints, margin {margin_used} mm')
else:
via_a = find_via_near(fa['x'], fa['y'], flayer, fnet)
via_b = find_via_near(ta['x'], ta['y'], tlayer, fnet)
if not via_a or not via_b: print('NO CLEAR VIA SPOT', from_ref, via_a, to_ref, via_b); sys.exit(1)
mid, margin_used = route_escalating(via_a, via_b, fnet, MIDLAYER)
if mid is None: print('NO PATH on', MIDLAYER, 'between', via_a, via_b); sys.exit(1)
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)
pts = [(fa['x'], fa['y'])] + [via_a] + mid[1:-1] + [via_b] + [(ta['x'], ta['y'])]
board_pts = []
if via_a is None:
board_pts = [{'x': p[0], 'y': p[1]} for p in mid]
else:
board_pts.append({'x': fa['x'], 'y': fa['y']})
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]})
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={{0.15}} pcbPath={{[' + ', '.join(fmt(d) for d in local) + f']}} /> {{/* {comp["source_component_id"]} centre ({ccx},{ccy}) rot {crot} */}}')