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/import-router-session.py

#!/usr/bin/env python3
"""Import a Freerouting Specctra session (SES) as explicit tscircuit copper legs.

The router returns loose wire fragments and vias per net. tscircuit renders copper as `<trace from to pcbPath>`
legs that must start and end on lands, so the fragments are stitched into a graph (T-junctions split, vias join
layers, the inner1 ground plane is a node) and decomposed into land-to-land legs (shortest graph path from every
land to the nearest already-served land) or, for ground, land-to-plane stubs ending in a via. Shared trunk copper
is repeated in each leg (same-net overlap, electrically identical).

Usage: python3 scripts/import-router-session.py tracker [--ses tmp/router/tracker.ses]
Reads dist/<board>/circuit.json (the export build), tmp/router/<board>-pad-map.json, the SES; writes
routing/<board>-routes.json rendered by lib/routed-traces.tsx.
"""
import json, sys, math, heapq, hashlib, re
board_key = sys.argv[1] if len(sys.argv) > 1 else 'tracker'
ses_path = sys.argv[sys.argv.index('--ses') + 1] if '--ses' in sys.argv else f'tmp/router/{board_key}.ses'
LAYERS = ['top', 'inner1', 'inner2', 'bottom']
PLANE = 'inner1'
GND = 'GND'

# ---------------------------------------------------------------- S-expression parser
def parse(text):
    tokens = re.findall(r'\(|\)|"[^"]*"|[^\s()"]+', text)
    pos = 0
    def node():
        nonlocal pos
        tok = tokens[pos]; pos += 1
        if tok == '(':
            out = []
            while tokens[pos] != ')': out.append(node())
            pos += 1
            return out
        return tok.strip('"')
    return node()
def find(node, head):
    return [n for n in node if isinstance(n, list) and n and n[0] == head]
ses = parse(open(ses_path).read())
routes = find(ses, 'routes')[0]
res = find(routes, 'resolution')[0]
assert res[1] == 'um', res
scale = 1 / (1000 * float(res[2]))                     # SES units -> mm
placement = find(ses, 'placement')[0]
pres = find(placement, 'resolution')[0]; pscale = 1 / (1000 * float(pres[2]))

pad_map = json.load(open(f'tmp/router/{board_key}-pad-map.json'))
raw = open(f'dist/{board_key}/circuit.json', 'rb').read()
if hashlib.sha256(raw).hexdigest() != pad_map['source_sha256']:
    raise SystemExit('dist/%s/circuit.json changed since the DSN export; re-export and re-route' % board_key)
c = json.loads(raw)
lands = pad_map['lands']
# the session must echo every land exactly where it was exported
placed = {}
for comp in find(placement, 'component'):
    for pl in find(comp, 'place'): placed[pl[1]] = (float(pl[2]) * pscale, float(pl[3]) * pscale, pl[4], pl[5])
for l in lands:
    p = placed.get(l['pin'])
    assert p and abs(p[0] - l['x']) < 1e-3 and abs(p[1] - l['y']) < 1e-3 and p[2] == 'front', ('land moved', l['pin'], p)

# ---------------------------------------------------------------- land geometry / component frames
def inside(l, x, y, tol=0.002):
    g = l['geo']; dx, dy = x - l['x'], y - l['y']
    if g['shape'] == 'circle': return math.hypot(dx, dy) <= g['d'] / 2 + tol
    r = -math.radians(g['rot']); rx, ry = dx * math.cos(r) - dy * math.sin(r), dx * math.sin(r) + dy * math.cos(r)
    return abs(rx) <= g['w'] / 2 + tol and abs(ry) <= g['h'] / 2 + tol
pcb_comps = {e['pcb_component_id']: e for e in c if e['type'] == 'pcb_component'}
land_comp = {}
for e in c:
    if e['type'] in ('pcb_smtpad', 'pcb_plated_hole'):
        land_comp[e.get('pcb_smtpad_id') or e.get('pcb_plated_hole_id')] = e.get('pcb_component_id')
# fixed copper that the router may echo
fixed_segs, fixed_vias = [], []
for t in c:
    if t['type'] != 'pcb_trace': continue
    prev = None
    for pt in t['route']:
        if pt['route_type'] == 'via': fixed_vias.append((pt['x'], pt['y'])); prev = None; continue
        if prev and prev['layer'] == pt['layer']: fixed_segs.append((prev['layer'], (prev['x'], prev['y']), (pt['x'], pt['y'])))
        prev = pt
def on_seg(p, a, b, tol=0.003):
    vx, vy = b[0] - a[0], b[1] - a[1]; L = math.hypot(vx, vy) or 1e-9
    t = ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / (L * L)
    if t < -tol / L or t > 1 + tol / L: return False
    return abs((p[0] - a[0]) * vy - (p[1] - a[1]) * vx) / L <= tol

# ---------------------------------------------------------------- per-net stitching
legs, report = [], {'nets': {}, 'dropped_fixed_wires': 0, 'dropped_fixed_vias': 0, 'floating_fragments': 0, 'uncovered_lands': []}
net_lands = {}
for l in lands:
    if l['net']: net_lands.setdefault(l['net'], []).append(l)
network = find(routes, 'network_out')[0]
for net_node in find(network, 'net'):
    net = net_node[1]
    wires, vias = [], []
    for w in find(net_node, 'wire'):
        path = find(w, 'path')[0]
        layer, width = path[1], float(path[2]) * scale
        pts = [(float(path[i]) * scale, float(path[i + 1]) * scale) for i in range(3, len(path), 2)]
        if all(any(s[0] == layer and on_seg(a, s[1], s[2]) and on_seg(b, s[1], s[2]) for s in fixed_segs) for a, b in zip(pts, pts[1:])):
            report['dropped_fixed_wires'] += 1; continue
        wires.append((layer, width, pts))
    for v in find(net_node, 'via'):
        x, y = float(v[2]) * scale, float(v[3]) * scale
        if any(math.hypot(x - fx, y - fy) < 0.002 for fx, fy in fixed_vias): report['dropped_fixed_vias'] += 1; continue
        vias.append((x, y))
    if not wires and not vias: continue
    mylands = net_lands.get(net, [])
    # graph: vertices (layer, x, y); edges with length; T-junction splitting
    key = lambda layer, p: (layer, round(p[0], 4), round(p[1], 4))
    segs = []  # [layer, a, b, width]
    for layer, width, pts in wires:
        for a, b in zip(pts, pts[1:]):
            if math.hypot(a[0] - b[0], a[1] - b[1]) > 1e-6: segs.append([layer, a, b, width])
    endpoints = {(layer, pts[0]) for layer, _, pts in wires} | {(layer, pts[-1]) for layer, _, pts in wires}
    for vx, vy in vias:
        for layer in LAYERS: endpoints.add((layer, (vx, vy)))
    # split every segment at the endpoints / via positions that lie strictly inside it (one pass)
    by_layer = {}
    for el, e in endpoints: by_layer.setdefault(el, []).append(e)
    split = []
    for layer, a, b, width in segs:
        inner = [e for e in by_layer.get(layer, []) if key(layer, e) not in (key(layer, a), key(layer, b)) and on_seg(e, a, b)]
        inner.sort(key=lambda e: (e[0] - a[0]) ** 2 + (e[1] - a[1]) ** 2)
        pts = [a] + inner + [b]
        for u, v in zip(pts, pts[1:]):
            if key(layer, u) != key(layer, v): split.append([layer, u, v, width])
    segs = split
    adj = {}
    def link(u, v, w):
        adj.setdefault(u, []).append((v, w)); adj.setdefault(v, []).append((u, w))
    for layer, a, b, width in segs: link(key(layer, a), key(layer, b), math.hypot(a[0] - b[0], a[1] - b[1]))
    via_nodes = {}
    for vx, vy in vias:
        vn = ('via', round(vx, 4), round(vy, 4)); via_nodes[vn] = (vx, vy)
        for layer in LAYERS:
            k = key(layer, (vx, vy))
            if k in adj or layer == PLANE: link(vn, k, 0.0)
        if net == GND: link(key(PLANE, (vx, vy)), ('plane',), 0.0)
    land_nodes = {}
    for l in mylands:
        ln = ('land', l['pin']); land_nodes[ln] = l
        for k in list(adj):
            if k[0] in l['layers'] and inside(l, k[1], k[2]): link(ln, k, 0.0)
    # shortest paths between terminals through wire vertices / vias only
    def dijkstra(src, allowed_targets):
        dist, prev, heap = {src: 0.0}, {}, [(0.0, src)]
        while heap:
            d, u = heapq.heappop(heap)
            if d > dist.get(u, 1e18): continue
            if u != src and u in allowed_targets: return d, u, prev
            if u != src and (u[0] in ('land', 'plane')): continue   # terminals are not pass-through
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist.get(v, 1e18): dist[v] = nd; prev[v] = u; heapq.heappush(heap, (nd, v))
        return None
    def walk(prev, src, dst):
        path, u = [dst], dst
        while u != src: u = prev[u]; path.append(u)
        return path[::-1]
    def to_pcb_path(nodes):
        out, layer = [], None
        for n in nodes:
            if n[0] == 'via' or n[0] in ('land', 'plane'): continue
            if layer is None: layer = n[0]
            if n[0] != layer:
                out.append({'x': out[-1]['x'], 'y': out[-1]['y'], 'via': True, 'toLayer': n[0]}); layer = n[0]
                if abs(out[-1]['x'] - n[1]) > 1e-6 or abs(out[-1]['y'] - n[2]) > 1e-6: out.append({'x': n[1], 'y': n[2]})
                continue
            if out and abs(out[-1]['x'] - n[1]) < 1e-6 and abs(out[-1]['y'] - n[2]) < 1e-6 and not out[-1].get('via'): continue
            out.append({'x': n[1], 'y': n[2]})
        return out
    def leg_width(nodes):
        ws = [w for layer, a, b, w in segs for n in nodes if n[0] == layer and key(layer, a) == n]
        return round(max(ws) if ws else 0.15, 3)
    served = set()
    net_report = {'lands': len(mylands), 'legs': 0, 'stubs': 0, 'unrouted': []}
    # components
    seen, comps = set(), []
    for start in list(adj):
        if start in seen: continue
        comp, stack = set(), [start]
        while stack:
            u = stack.pop()
            if u in comp: continue
            comp.add(u); stack.extend(v for v, _ in adj.get(u, []))
        seen |= comp; comps.append(comp)
    for comp in comps:
        clands = [n for n in comp if n[0] == 'land']
        if not clands: report['floating_fragments'] += 1; continue
        targets = set(clands[1:]) if len(clands) > 1 else set()
        order = [clands[0]]; pending = set(clands[1:])
        if net == GND and ('plane',) in comp:
            # every ground land gets its own stub to the plane when it can reach a via directly
            for ln in clands:
                r = dijkstra(ln, {('plane',)})
                if r:
                    nodes = walk(r[2], ln, r[1]); pth = to_pcb_path(nodes)
                    if pth and pth[-1].get('via') and pth[-1]['toLayer'] == PLANE: pth = pth[:-1]
                    pth.append({'x': pth[-1]['x'], 'y': pth[-1]['y'], 'via': True, 'toLayer': PLANE}) if pth else None
                    if not pth:  # via sits inside the land itself
                        l = land_nodes[ln]; pth = [{'x': r[1][1] if False else l['x'], 'y': l['y'], 'via': True, 'toLayer': PLANE}]
                    legs.append({'net': net, 'from': land_nodes[ln]['ref'], 'to': f'net.{GND}', 'width': leg_width(nodes), 'pcb_land': land_nodes[ln]['land'], 'path': pth})
                    served.add(ln); net_report['stubs'] += 1
            pending = {ln for ln in clands if ln not in served}
            if not pending: continue
            order = [next(iter(served & set(clands)))] if served & set(clands) else [pending.pop()]
        served.add(order[0])
        while pending:
            best = None
            for ln in pending:
                r = dijkstra(ln, served & set(clands))
                if r and (best is None or r[0] < best[0]): best = (r[0], ln, r[1], r[2])
            if not best:
                net_report['unrouted'].extend(land_nodes[ln]['ref'] for ln in pending); break
            d, ln, dst, prev = best
            nodes = walk(prev, ln, dst)
            legs.append({'net': net, 'from': land_nodes[ln]['ref'], 'to': land_nodes[dst]['ref'], 'width': leg_width(nodes), 'pcb_land': land_nodes[ln]['land'], 'path': to_pcb_path(nodes)})
            served.add(ln); pending.discard(ln); net_report['legs'] += 1
    report['nets'][net] = net_report

# ---------------------------------------------------------------- component frames + output
for i, leg in enumerate(legs):
    comp = pcb_comps[land_comp[leg.pop('pcb_land')]]
    leg['id'] = f"fr_{i}_{leg['from'].replace('.', '_')}"
    leg['fromCentre'] = {'x': comp['center']['x'], 'y': comp['center']['y']}
    leg['fromRotation'] = comp.get('rotation') or 0
    leg['path'] = [{**p, 'x': round(p['x'], 4), 'y': round(p['y'], 4)} for p in leg['path']]
# lands the router never touched (still connected only by name)
covered = {l['from'] for l in legs} | {l['to'] for l in legs}
fixed_ports = set()
for t in c:
    if t['type'] == 'pcb_trace':
        for pt in t['route']:
            for k in ('start_pcb_port_id', 'end_pcb_port_id'):
                if pt.get(k): fixed_ports.add(pt[k])
for l in lands:
    if l['net'] and l['ref'] not in covered and l['port'] not in fixed_ports and len(net_lands[l['net']]) > 1:
        report['uncovered_lands'].append(f"{l['ref']} ({l['net']})")
if '--append' in sys.argv:
    prev = json.load(open(f'routing/{board_key}-routes.json'))['legs']
    for i, leg in enumerate(legs): leg['id'] = f"fr_{len(prev) + i}_{leg['from'].replace('.', '_')}"
    print(f'append mode: keeping {len(prev)} existing legs, adding {len(legs)}')
    legs = prev + legs
out = {'source': pad_map['source_sha256'], 'router': 'Freerouting 1.9.0 (local, ' + ses_path + ')', 'legs': legs}
json.dump(out, open(f'routing/{board_key}-routes.json', 'w'), indent=1)
json.dump(report, open(f'tmp/router/{board_key}-import-report.json', 'w'), indent=1)
print(f"{len(legs)} legs written; dropped fixed echo {report['dropped_fixed_wires']} wires / {report['dropped_fixed_vias']} vias; floating fragments {report['floating_fragments']}")
unr = {n: r['unrouted'] for n, r in report['nets'].items() if r['unrouted']}
print('unrouted lands (router):', unr if unr else 'none')
print('uncovered lands (no copper at all):', report['uncovered_lands'] if report['uncovered_lands'] else 'none')