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