Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion pcbsight/pcbsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
is IPC-2221 arithmetic, and pair skew is a subtraction. So compute them.

from pcbsight import parse_board, analyze

Beyond review there is an EXPERIMENTAL create side (WIP, no tests yet):
`Design` is an intent API (part/net/place/pour), `ARouter` is a
clearance-correct A* autorouter, and `library` holds real footprints. It
emits a board that the review side then validates.

from pcbsight import Design, ARouter # experimental
"""

__version__ = "0.6.0"
__version__ = "0.7.0"

from .board import Arc, Board, Pad, Track, Via, Zone, parse_board
from .sexpr import dumps as sexpr_dumps
Expand All @@ -18,10 +25,20 @@
from .errors import BadArgumentError, BadBoardError, PCBSightError
from .report import analyze, inspect

# --- experimental create side (WIP) ---------------------------------------
from . import library
from .aroute import ARouter
from .design import Component, Design, Pin
from .library import get as get_footprint
from .router import Router

__all__ = [
"parse_board", "Board", "Track", "Via", "Pad", "Arc", "Zone",
"load_sexpr", "save_sexpr", "sexpr_dumps",
"analyze", "inspect", "connectivity", "clearance",
"current_capacity", "diff_pairs", "microstrip_z0",
"PCBSightError", "BadBoardError", "BadArgumentError",
# experimental create side
"Design", "Component", "Pin", "ARouter", "Router",
"library", "get_footprint",
]
340 changes: 340 additions & 0 deletions pcbsight/pcbsight/aroute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
"""A clearance-correct autorouter — "a router an AI can trust".

The toy maze router laid copper its own clearance halo allowed but that
pcbsight then flagged, because it never reserved the width of the trace it
was *currently* laying. This one does: when routing a net of width w, every
obstacle (foreign pad, foreign trace, via) is inflated by

obstacle_half + clearance + w/2

so the routed centre-line is at least (clearance + w/2 + obstacle_half)
from any foreign copper, and therefore the finished edge-to-edge gap is
>= clearance BY CONSTRUCTION. No finding can appear.

A* over (layer, x, y) on a fine grid, two copper layers joined by vias,
multi-terminal nets grown terminal by terminal, and a rip-up-and-retry
outer loop so a net that gets boxed in is retried after the others move.
"""

from __future__ import annotations

import heapq
import math


class ARouter:
def __init__(self, x0, y0, w, h, grid=0.15, clearance=0.2,
via_cost=30.0, via_size=0.6, hweight=1.8, budget=120000):
self.x0, self.y0 = x0, y0
self.grid = grid
self.clear = clearance
self.via_cost = via_cost
self.via_size = via_size
# weighted A*: focus the search toward the goal so it terminates in
# far fewer nodes on big open boards (and actually reaches the
# second-layer detour instead of sweeping F.Cu until budget runs
# out). hweight>1 trades slightly longer traces for a huge speedup.
self.hweight = hweight
self.budget = budget
self.nx = int(math.ceil(w / grid)) + 1
self.ny = int(math.ceil(h / grid)) + 1
self.pads = [] # (cx, cy, half, net, layers:tuple[int])
self.obst = [] # laid copper: ('seg', a, b, half, net, layer)
self.vias = [] # (cx, cy, half, net)
self.out_segs = [] # (x1,y1,x2,y2,width,layer,net) world
self.out_vias = [] # (x,y,size,net) world

# --- coordinate helpers --------------------------------------------
def _c(self, x, y):
return (int(round((x - self.x0) / self.grid)),
int(round((y - self.y0) / self.grid)))

def _w(self, ix, iy):
return (self.x0 + ix * self.grid, self.y0 + iy * self.grid)

def _in(self, ix, iy):
return 0 <= ix < self.nx and 0 <= iy < self.ny

def add_pad(self, x, y, w, h, net, through):
cx, cy = self._c(x, y)
layers = (0, 1) if through else (0,)
# store the pad as a RECTANGLE (half-dims in cells); a circle of
# its long side would over-block the gaps between fine-pitch pins
self.pads.append((cx, cy, (w / 2) / self.grid, (h / 2) / self.grid,
net, layers))

# --- rect / segment stamps into an arbitrary mask ------------------
def _stamp_rect(self, buf, cx, cy, hw, hh, reach):
"""Mark every cell within `reach` mm of the rectangle centred at
(cx,cy) with half-extents (hw,hh) cells. buf is a flat bytearray."""
rc = reach / self.grid
rx = int(math.ceil(hw + rc))
ry = int(math.ceil(hh + rc))
for dy in range(-ry, ry + 1):
yy = cy + dy
if not (0 <= yy < self.ny):
continue
ddy = max(abs(dy) - hh, 0.0)
row = yy * self.nx
for dx in range(-rx, rx + 1):
xx = cx + dx
if not (0 <= xx < self.nx):
continue
ddx = max(abs(dx) - hw, 0.0)
if ddx * ddx + ddy * ddy <= rc * rc:
buf[row + xx] = 1

def _stamp_seg(self, buf, ax, ay, bx, by, reach):
r = int(math.ceil(reach / self.grid))
lo_x, hi_x = min(ax, bx) - r, max(ax, bx) + r
lo_y, hi_y = min(ay, by) - r, max(ay, by) + r
for yy in range(max(0, lo_y), min(self.ny, hi_y + 1)):
row = yy * self.nx
for xx in range(max(0, lo_x), min(self.nx, hi_x + 1)):
if _pt_seg_cells(xx, yy, ax, ay, bx, by) <= r:
buf[row + xx] = 1

# --- per-net blocked map (the clearance-correct part) --------------
def _blocked(self, net, width):
"""Returns (blk, vblk):
blk[L] - cells a TRACE centre of this net may not occupy on layer L
(foreign copper inflated by clearance + trace_half).
vblk - cells a VIA of this net may not be centred on (either
layer), foreign copper inflated by clearance + VIA_half.
The via mask is the fix for vias sliding into the clearance zone: a
via is ~0.6 mm, far wider than the trace, so it needs its own, larger
halo — checking it against the trace map let vias sit too close."""
half = width / 2.0
vhalf = self.via_size / 2.0
blk = [bytearray(self.nx * self.ny) for _ in range(2)]
vblk = bytearray(self.nx * self.ny)

for (cx, cy, hw, hh, pnet, players) in self.pads:
if pnet == net:
continue
for L in players:
self._stamp_rect(blk[L], cx, cy, hw, hh, self.clear + half)
self._stamp_rect(vblk, cx, cy, hw, hh, self.clear + vhalf)
for (cx, cy, vh, vnet) in self.vias:
if vnet == net:
continue
self._stamp_rect(blk[0], cx, cy, 0, 0, vh + self.clear + half)
self._stamp_rect(blk[1], cx, cy, 0, 0, vh + self.clear + half)
self._stamp_rect(vblk, cx, cy, 0, 0, vh + self.clear + vhalf)
for kind, a, b, oh, onet, oL in self.obst:
if onet == net:
continue
(ax, ay), (bx, by) = a, b
self._stamp_seg(blk[oL], ax, ay, bx, by, oh + self.clear + half)
self._stamp_seg(vblk, ax, ay, bx, by, oh + self.clear + vhalf)
return blk, vblk

# --- A* between a set of source cells and a set of target cells ----
def _astar(self, sources, targets, blk, vblk, anchors=None):
tgt = set(targets)
pq = []
g = {}
prev = {}
# heuristic = Manhattan distance to the NEAREST remaining terminal
# (anchor points), not the centroid: a net spread across the whole
# board (GND, 3V3) would otherwise be pulled toward the middle and
# never reach its far terminals within budget.
if anchors is None:
cx = sum(t[1] for t in tgt) / len(tgt)
cy = sum(t[2] for t in tgt) / len(tgt)
anchors = ((cx, cy),)
axs = [a[0] for a in anchors]
ays = [a[1] for a in anchors]
na = len(axs)

def hmin(x, y):
best = 1e18
for i in range(na):
dxi = axs[i] - x
if dxi < 0:
dxi = -dxi
dyi = ays[i] - y
if dyi < 0:
dyi = -dyi
d = dxi + dyi
if d < best:
best = d
return best

W = self.hweight
for s in sources:
g[s] = 0.0
h = hmin(s[1], s[2])
heapq.heappush(pq, (W * h, h, s))
budget = self.budget
while pq:
budget -= 1
if budget <= 0:
return None
_f, _hh, cur = heapq.heappop(pq)
if cur in tgt:
return self._trace(prev, cur, sources)
cg = g[cur]
L, x, y = cur
for nL, nx_, ny_, step in ((L, x + 1, y, 1.0), (L, x - 1, y, 1.0),
(L, x, y + 1, 1.0), (L, x, y - 1, 1.0),
(1 - L, x, y, self.via_cost)):
if not self._in(nx_, ny_):
continue
nxt = (nL, nx_, ny_)
cell = ny_ * self.nx + nx_
if nL != L: # via move: ALWAYS respect the
if vblk[cell]: # via halo, even onto a target
continue # (else two nets' vias can stack)
elif nxt not in tgt and blk[nL][cell]: # ortho: trace halo
continue
ng = cg + step
if ng < g.get(nxt, 1e18):
g[nxt] = ng
prev[nxt] = cur
h = hmin(nx_, ny_)
heapq.heappush(pq, (ng + W * h, h, nxt))
return None

def _h(self, node, tx, ty):
return abs(node[1] - tx) + abs(node[2] - ty)

def _trace(self, prev, end, sources):
path = [end]
while path[-1] not in sources:
p = prev.get(path[-1])
if p is None:
break
path.append(p)
path.reverse()
return path

# --- route one net (all its terminals) -----------------------------
def route_net(self, net, terminals, width, partial=False):
"""terminals: list of (x, y, through). Returns True if all joined
(partial=True: lay what is reachable, always return True)."""
if len(terminals) < 2:
return True
blk, vblk = self._blocked(net, width)
cells = []
for (x, y, th) in terminals:
cx, cy = self._c(x, y)
layers = (0, 1) if th else (0,)
cells.append({(L, cx, cy) for L in layers})
routed = set(cells[0])
remaining = list(cells[1:])
laid = []
while remaining:
# ONE multi-target A* from the whole tree to the union of all
# remaining terminals; connect whichever is reached first. This is
# O(k) A* calls instead of O(k^2) — essential for big nets like a
# 45-pad GND, where the per-terminal scan was pathologically slow.
all_targets = set()
cell2term = {}
anchors = []
for i, t in enumerate(remaining):
for c in t:
all_targets.add(c)
cell2term[c] = i
ac = next(iter(t)) # one anchor per terminal
anchors.append((ac[1], ac[2]))
path = self._astar(routed, all_targets, blk, vblk, anchors)
if path is None or cell2term.get(path[-1]) is None:
# no remaining terminal reachable from the tree so far
if partial: # lay what we have, drop the rest
break
return False
laid.append(path)
routed |= set(path)
remaining.pop(cell2term[path[-1]])
for path in laid:
self._lay(net, path, width)
# partial (GND): always keep the copper we managed to lay; the poured
# plane + these vias carry the rest. A hard net must connect fully.
return True if partial else (len(remaining) == 0)

def _lay(self, net, path, width):
half = width / 2.0
i = 0
n = len(path)
while i < n - 1:
L, x, y = path[i]
L2, x2, y2 = path[i + 1]
if L2 != L: # via
self.vias.append((x, y, self.via_size / 2, net))
wx, wy = self._w(x, y)
self.out_vias.append((wx, wy, self.via_size, net))
i += 1
continue
dx, dy = x2 - x, y2 - y # straight run
j = i + 1
while j < n - 1:
Lj, xj, yj = path[j]
Lk, xk, yk = path[j + 1]
if Lk != Lj or (xk - xj, yk - yj) != (dx, dy):
break
j += 1
ex, ey = path[j][1], path[j][2]
self.obst.append(('seg', (x, y), (ex, ey), half, net, L))
ax, ay = self._w(x, y)
bx, by = self._w(ex, ey)
self.out_segs.append((ax, ay, bx, by, width,
"F.Cu" if L == 0 else "B.Cu", net))
i = j

def _reset_copper(self):
self.obst = []
self.vias = []
self.out_segs = []
self.out_vias = []

# --- rip-up-and-retry over a list of nets --------------------------
def route_all(self, nets, rounds=4, defer=()):
"""nets: list of (net_id, terminals, width). Returns failed ids.

Rip-up-and-retry: each round rips up ALL routing and re-routes,
giving the nets that failed last round FIRST pick of the board
(routed on the empty board, the others weave around them). Keeps
the best round. This breaks the congestion deadlocks that a single
greedy pass gets stuck in.

`defer` = net ids to route LAST (e.g. a big GND that is also poured):
a huge many-terminal net would otherwise be routed first and hog the
tight areas the fine-pitch signals need to escape."""
defer = set(defer)
best_segs = best_vias = best_failed = None
for rnd in range(rounds):
self._reset_copper()
if rnd == 0 or best_failed is None:
# deferred nets last; else hardest first (many-terminal, span)
key = lambda nt: (nt[0] in defer, -len(nt[1]), -_span(nt[1]))
else:
fset = set(best_failed)
key = lambda nt: (nt[0] in defer, nt[0] not in fset,
-len(nt[1]), -_span(nt[1]))
failed = []
for (nid, terms, w) in sorted(nets, key=key):
if not self.route_net(nid, terms, w, partial=(nid in defer)):
failed.append(nid)
if best_failed is None or len(failed) < len(best_failed):
best_failed, best_segs = failed, list(self.out_segs)
best_vias = list(self.out_vias)
if not failed:
break
self.out_segs, self.out_vias = best_segs, best_vias
return best_failed


def _pt_seg_cells(px, py, ax, ay, bx, by) -> float:
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
if L2 == 0:
return math.hypot(px - ax, py - ay)
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L2))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))


def _span(terminals):
xs = [t[0] for t in terminals]
ys = [t[1] for t in terminals]
return (max(xs) - min(xs)) + (max(ys) - min(ys))
Loading
Loading