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>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent c498882bbb
commit 1400cc8afd
46 files changed
+226470

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
"""Ground pour: raster fill on the router grid -> fractured polygons for KiCad.
Cells are squares of side G centred on grid points. A cell may be filled when no
foreign copper is within the pour clearance + half the cell diagonal; that radius
map already exists in the router (radius for a 0.2 mm track = 0.38 mm).
"""
from collections import deque
import router as R
def fill_mask(rt, net, layer, r_map, open_cells=2):
k = rt.nid(net)
nx, ny = rt.nx, rt.ny
m = rt.maps[r_map][layer]
mask = bytearray(nx * ny)
for i in range(nx * ny):
v = m[i]
if v == -1 or v == k:
mask[i] = 1
# morphological opening (erode then dilate, 4-neighbourhood) removes slivers
for _ in range(open_cells):
mask = _erode(mask, nx, ny)
for _ in range(open_cells):
mask = _dilate(mask, nx, ny, limit=m, k=k)
return mask
def _erode(mask, nx, ny):
out = bytearray(len(mask))
for iy in range(1, ny - 1):
b = iy * nx
for ix in range(1, nx - 1):
i = b + ix
if mask[i] and mask[i - 1] and mask[i + 1] and mask[i - nx] and mask[i + nx]:
out[i] = 1
return out
def _dilate(mask, nx, ny, limit, k):
out = bytearray(mask)
for iy in range(1, ny - 1):
b = iy * nx
for ix in range(1, nx - 1):
i = b + ix
if not mask[i] and (mask[i - 1] or mask[i + 1] or mask[i - nx] or mask[i + nx]):
v = limit[i]
if v == -1 or v == k:
out[i] = 1
return out
def keep_connected(mask, nx, ny, anchors):
"""Keep only 4-connected components that contain an anchor cell index."""
keep = bytearray(len(mask))
q = deque(i for i in anchors if mask[i])
for i in q:
keep[i] = 1
while q:
i = q.popleft()
for j in (i - 1, i + 1, i - nx, i + nx):
if 0 <= j < len(mask) and mask[j] and not keep[j]:
keep[j] = 1
q.append(j)
return keep
def contours(mask, nx, ny):
"""Boundary loops of the union of filled cells, in corner coordinates (cx, cy)
where cell (ix, iy) spans corners (ix..ix+1, iy..iy+1). Filled area on the left."""
edges = {}
def add(a, b):
edges.setdefault(a, []).append(b)
for iy in range(ny):
b = iy * nx
for ix in range(nx):
if not mask[b + ix]:
continue
# neighbours (outside = empty)
up = iy > 0 and mask[b + ix - nx]
dn = iy < ny - 1 and mask[b + ix + nx]
lf = ix > 0 and mask[b + ix - 1]
rt_ = ix < nx - 1 and mask[b + ix + 1]
# traverse cell boundary counter-clockwise in screen coords (y down): filled on left
if not up:
add((ix + 1, iy), (ix, iy))
if not lf:
add((ix, iy), (ix, iy + 1))
if not dn:
add((ix, iy + 1), (ix + 1, iy + 1))
if not rt_:
add((ix + 1, iy + 1), (ix + 1, iy))
loops = []
while edges:
start = next(iter(edges))
loop = [start]
cur = start
prev_dir = None
while True:
outs = edges[cur]
if len(outs) == 1:
nxt = outs.pop()
else:
# pinch point: prefer the turn that keeps the filled region on the left (turn left first)
best = None
for cand in outs:
d = (cand[0] - cur[0], cand[1] - cur[1])
if prev_dir is None:
best = cand
break
# left turn in y-down coords: (dx,dy) -> (dy,-dx)
if d == (prev_dir[1], -prev_dir[0]):
best = cand
break
if best is None:
best = outs[0]
outs.remove(best)
nxt = best
if not outs:
del edges[cur]
prev_dir = (nxt[0] - cur[0], nxt[1] - cur[1])
cur = nxt
if cur == start:
break
loop.append(cur)
loops.append(_simplify(loop))
return loops
def _simplify(loop):
out = []
n = len(loop)
for i in range(n):
a, b, c = loop[i - 1], loop[i], loop[(i + 1) % n]
if (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) != 0:
out.append(b)
return out
def area2(loop):
s = 0
for i in range(len(loop)):
x1, y1 = loop[i - 1]
x2, y2 = loop[i]
s += x1 * y2 - x2 * y1
return s
def point_in(loop, x, y):
inside = False
n = len(loop)
for i in range(n):
x1, y1 = loop[i - 1]
x2, y2 = loop[i]
if (y1 > y) != (y2 > y):
xi = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
if xi > x:
inside = not inside
return inside
def fracture(outer, holes):
"""Merge holes into the outer ring with zero-width bridges (KiCad's fractured form).
Rectilinear integer loops. Holes are bridged rightwards to the nearest vertical edge."""
ring = list(outer)
for hole in sorted(holes, key=lambda h: -max(p[0] for p in h)):
xh = max(p[0] for p in hole)
vi = min((i for i in range(len(hole)) if hole[i][0] == xh), key=lambda i: hole[i][1])
V = hole[vi]
best = None
n = len(ring)
for i in range(n):
A, B = ring[i], ring[(i + 1) % n]
if A[0] == B[0] and A[0] > V[0] and min(A[1], B[1]) <= V[1] <= max(A[1], B[1]):
if best is None or A[0] < best[0]:
best = (A[0], i)
if best is None:
continue
x, i = best
P = (x, V[1])
seq = hole[vi:] + hole[:vi]
ring = ring[:i + 1] + [P] + seq + [V, P] + ring[i + 1:]
out = []
for p in ring:
if not out or out[-1] != p:
out.append(p)
if len(out) > 1 and out[0] == out[-1]:
out.pop()
return out
def polygons(mask, nx, ny):
loops = contours(mask, nx, ny)
outers, holes = [], []
for lp in loops:
if len(lp) < 4:
continue
(outers if area2(lp) < 0 else holes).append(lp)
result = []
owner = {}
for h in holes:
x, y = h[0][0] + 1e-3, h[0][1] + 1e-3
cands = [o for o in outers if point_in(o, x, y)]
if cands:
owner[id(h)] = min(cands, key=lambda o: abs(area2(o)))
for o in outers:
hs = [h for h in holes if owner.get(id(h)) is o]
result.append(fracture(o, hs))
return result
def to_mm(poly, G):
return [((cx - 0.5) * G, (cy - 0.5) * G) for (cx, cy) in poly]