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')