Files
ckochandClaude Opus 5.5 1400cc8afd Controller board: ESP32 + 2x StepStick carrier (v0.4), FluidNC config
- KiCad project generated by pcb/tools (design, route, schematic, fab outputs).
- v0.4 fixes the v0.3 review findings: low-dropout 3.3 V regulator, OR'd 5 V
  supply, polyfuse, USB ESD protection, input clamps, ceramic decoupling at the
  drivers, A4988/DRV8825 jumper, ground stitching. DRC/ERC clean.
- v0.3 (as ordered) kept in pcb/archive/v0.3.
- FluidNC config.yaml for the board; rho hard limit on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:40:23 -05:00

290 lines
12 KiB
Python

"""A small two-layer grid router (A* with turn penalties, vias, per-net widths).
Obstacle model: for every distinct "inflation radius" R (track half-width +
clearance + discretisation margin) we keep, per layer, an owner map:
-1 = free, k >= 0 = only copper of net k is within R, -2 = blocked for everyone.
A cell is usable by net k iff its owner is -1 or k.
"""
import heapq
import math
from array import array
G = 0.1 # grid pitch, mm
CLEAR = 0.2 # copper-copper clearance used by the router (DRC default is 0.15)
EDGE = 0.35 # copper-to-board-edge clearance
MARGIN = 0.08 # discretisation safety margin
VIA_D = 0.6
COST_STEP = 10
COST_DIAG = 14
COST_VIA = 90
COST_TURN = 4
COST_BCU = 1 # tiny extra per step on the bottom layer (keeps B.Cu freer for GND)
DIRS = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
def seg_dist(px, py, x1, y1, x2, y2):
dx, dy = x2 - x1, y2 - y1
L2 = dx * dx + dy * dy
if L2 == 0:
return math.hypot(px - x1, py - y1)
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / L2))
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
class Router:
def __init__(self, width, height, radii):
self.W, self.H = width, height
self.nx = int(round(width / G)) + 1
self.ny = int(round(height / G)) + 1
self.radii = sorted(set(round(r, 3) for r in radii))
n = self.nx * self.ny
self.maps = {r: [array('h', [-1]) * n, array('h', [-1]) * n] for r in self.radii}
self.net_index = {}
self.objects = [] # (kind, data, net) for re-rasterising
self.tracks = [] # (x1,y1,x2,y2,w,layer,net)
self.vias = [] # (x,y,net)
self.net_cells = {} # net -> set of (layer, ix, iy) owned copper cells (tree)
self.no_via = bytearray(n) # 1 = a via centred here would touch or crowd a pad (any net)
# ------------------------------------------------------------ helpers
def nid(self, net):
if net is None:
return -2
if net not in self.net_index:
self.net_index[net] = len(self.net_index)
return self.net_index[net]
def cell(self, x, y):
return int(round(x / G)), int(round(y / G))
def xy(self, ix, iy):
return ix * G, iy * G
def _mark(self, layer_ids, net, x0, y0, x1, y1, inside):
"""inside(px,py,R) -> True if cell centre is within R of the object."""
k = self.nid(net)
for r in self.radii:
ix0 = max(0, int((x0 - r) / G) - 1)
iy0 = max(0, int((y0 - r) / G) - 1)
ix1 = min(self.nx - 1, int((x1 + r) / G) + 1)
iy1 = min(self.ny - 1, int((y1 + r) / G) + 1)
for L in layer_ids:
m = self.maps[r][L]
for iy in range(iy0, iy1 + 1):
py = iy * G
base = iy * self.nx
for ix in range(ix0, ix1 + 1):
idx = base + ix
v = m[idx]
if v == -2 or v == k:
continue
if inside(ix * G, py, r):
m[idx] = k if v == -1 else -2
def add_pad(self, pad):
layers = [0 if l == 'F.Cu' else 1 for l in pad.layers]
if pad.kind == 'np_thru_hole':
layers = [0, 1]
hole = pad.drill[0] / 2 if pad.drill else pad.hx
cx, cy = pad.x, pad.y
self._mark(layers, None, cx - hole, cy - hole, cx + hole, cy + hole,
lambda px, py, r: math.hypot(px - cx, py - cy) < hole + r)
return
net = pad.net
self._mark(layers, net, pad.x - pad.hx, pad.y - pad.hy, pad.x + pad.hx, pad.y + pad.hy,
lambda px, py, r: pad.dist(px, py) < r)
# drilled THT pads also block the other layer for different nets via the copper ring (both layers)
def keep_vias_off_pad(self, pad, gap):
"""Forbid via centres within (via radius + gap) of this pad's copper, whatever its net.
A via in or touching a pad lets solder drain into the hole during assembly."""
if pad.kind == 'np_thru_hole' or not pad.layers:
return
r = VIA_D / 2 + gap
ix0, iy0 = max(0, int((pad.x - pad.hx - r) / G) - 1), max(0, int((pad.y - pad.hy - r) / G) - 1)
ix1, iy1 = min(self.nx - 1, int((pad.x + pad.hx + r) / G) + 1), min(self.ny - 1, int((pad.y + pad.hy + r) / G) + 1)
for iy in range(iy0, iy1 + 1):
for ix in range(ix0, ix1 + 1):
if pad.dist(ix * G, iy * G) < r:
self.no_via[iy * self.nx + ix] = 1
def add_track(self, x1, y1, x2, y2, w, layer, net):
hw = w / 2
self._mark([layer], net, min(x1, x2) - hw, min(y1, y2) - hw, max(x1, x2) + hw, max(y1, y2) + hw,
lambda px, py, r: seg_dist(px, py, x1, y1, x2, y2) < hw + r)
self.tracks.append((x1, y1, x2, y2, w, 'F.Cu' if layer == 0 else 'B.Cu', net))
def add_via(self, x, y, net):
rv = VIA_D / 2
self._mark([0, 1], net, x - rv, y - rv, x + rv, y + rv,
lambda px, py, r: math.hypot(px - x, py - y) < rv + r)
self.vias.append((x, y, net))
def block_edges(self):
for r in self.radii:
lim = EDGE + r - CLEAR # r already includes clearance; edge rule replaces it
n = int(math.ceil(lim / G)) # cells with index < n (or > max-n) are too close
for L in (0, 1):
m = self.maps[r][L]
for iy in range(self.ny):
base = iy * self.nx
if iy < n or iy > self.ny - 1 - n:
for ix in range(self.nx):
m[base + ix] = -2
else:
for ix in list(range(n)) + list(range(self.nx - n, self.nx)):
m[base + ix] = -2
def block_rect(self, x0, y0, x1, y1):
"""Keep-out: nothing may enter (tracks are inflated by their radius)."""
for r in self.radii:
for L in (0, 1):
m = self.maps[r][L]
hw = r - CLEAR - MARGIN # track half width
for iy in range(max(0, int((y0 - hw) / G)), min(self.ny, int((y1 + hw) / G) + 2)):
for ix in range(max(0, int((x0 - hw) / G)), min(self.nx, int((x1 + hw) / G) + 2)):
px, py = ix * G, iy * G
if x0 - hw - CLEAR <= px <= x1 + hw + CLEAR and y0 - hw - CLEAR <= py <= y1 + hw + CLEAR:
m[iy * self.nx + ix] = -2
# ------------------------------------------------------------ search
def pad_cells(self, pad):
layers = [0 if l == 'F.Cu' else 1 for l in pad.layers]
cells = set()
ix0, iy0 = int((pad.x - pad.hx) / G), int((pad.y - pad.hy) / G)
ix1, iy1 = int((pad.x + pad.hx) / G) + 1, int((pad.y + pad.hy) / G) + 1
for iy in range(iy0, iy1 + 1):
for ix in range(ix0, ix1 + 1):
if pad.dist(ix * G, iy * G) <= 1e-9:
for L in layers:
cells.add((L, ix, iy))
if not cells:
ix, iy = self.cell(pad.x, pad.y)
for L in layers:
cells.add((L, ix, iy))
return cells
def search(self, net, r_track, r_via, sources, targets, tx, ty, max_expand=1500000):
k = self.nid(net)
nx = self.nx
mt = self.maps[r_track]
mv = self.maps[r_via]
own = self.net_cells.setdefault(net, set())
tset = targets
txi, tyi = tx / G, ty / G
def free(L, ix, iy):
if ix < 0 or iy < 0 or ix >= nx or iy >= self.ny:
return False
if (L, ix, iy) in tset:
return True
v = mt[L][iy * nx + ix]
return v == -1 or v == k
def via_ok(ix, iy):
if self.no_via[iy * nx + ix]:
return False
for L in (0, 1):
v = mv[L][iy * nx + ix]
if not (v == -1 or v == k):
return False
return True
def h(ix, iy):
dx, dy = abs(ix - txi), abs(iy - tyi)
return int(COST_STEP * (max(dx, dy) - min(dx, dy)) + COST_DIAG * min(dx, dy))
openh = []
g = {}
came = {}
for (L, ix, iy) in sources:
s = (L, ix, iy, 8)
g[s] = 0
heapq.heappush(openh, (h(ix, iy), 0, s))
expanded = 0
while openh:
f, gc, s = heapq.heappop(openh)
if g.get(s, 1 << 60) < gc:
continue
L, ix, iy, d = s
if (L, ix, iy) in tset:
path = [(L, ix, iy)]
while s in came:
s = came[s]
path.append(s[:3])
path.reverse()
return path
expanded += 1
if expanded > max_expand:
return None
for nd, (dx, dy) in enumerate(DIRS):
jx, jy = ix + dx, iy + dy
if not free(L, jx, jy):
continue
if dx and dy:
# don't cut corners between two blocked orthogonal neighbours
if not (free(L, ix + dx, iy) or free(L, ix, iy + dy)):
continue
step = COST_DIAG
else:
step = COST_STEP
if d != 8 and nd != d:
step += COST_TURN * (1 if (nd - d) % 8 in (1, 7) else 3)
if L == 1:
step += COST_BCU
ns = (L, jx, jy, nd)
ng = gc + step
if ng < g.get(ns, 1 << 60):
g[ns] = ng
came[ns] = s
heapq.heappush(openh, (ng + h(jx, jy), ng, ns))
# via
if via_ok(ix, iy):
ns = (1 - L, ix, iy, 8)
ng = gc + COST_VIA
if ng < g.get(ns, 1 << 60) and free(1 - L, ix, iy):
g[ns] = ng
came[ns] = s
heapq.heappush(openh, (ng + h(ix, iy), ng, ns))
return None
def commit_path(self, net, path, w, start_xy=None, end_xy=None):
"""Turn a cell path into merged segments + vias, add them as obstacles."""
own = self.net_cells.setdefault(net, set())
pts = [] # (layer, x, y)
for (L, ix, iy) in path:
pts.append((L, ix * G, iy * G))
own.add((L, ix, iy))
segs = []
vias = []
run = [pts[0]]
for p in pts[1:]:
if p[0] != run[-1][0]:
vias.append((p[1], p[2]))
if len(run) > 1:
segs.append(run)
run = [p]
else:
run.append(p)
if len(run) > 1:
segs.append(run)
out = []
for run in segs:
# merge collinear points
simp = [run[0]]
for i in range(1, len(run) - 1):
a, b, c = simp[-1], run[i], run[i + 1]
d1 = (round((b[1] - a[1]) / G), round((b[2] - a[2]) / G))
d2 = (round((c[1] - b[1]) / G), round((c[2] - b[2]) / G))
if d1[0] * d2[1] - d1[1] * d2[0] != 0 or d1[0] * d2[0] + d1[1] * d2[1] < 0:
simp.append(b)
simp.append(run[-1])
for a, b in zip(simp, simp[1:]):
out.append((a[1], a[2], b[1], b[2], w, a[0], net))
for (x, y) in vias:
self.add_via(x, y, net)
for (x1, y1, x2, y2, ww, L, n) in out:
self.add_track(x1, y1, x2, y2, ww, L, n)
return len(out), len(vias)