board
RITHESH LED Display
Public Unreviewedby Rithesh03
A 300 x 70 mm PCB that spells RITHESH with 107 WS2812B RGB LEDs, controlled over Wi-Fi.
main
Rithesh03
Update 3: PCB design completed - routed board, manufacturing package (prototype, do not order yet), WLED guide, final checklist, Hydrogen feedback
1ed072d
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910
"""Routing, in stages (run after place_parts.py). Each stage saves the board.
python3 route_board.py pre Stage R1: hand-routed critical copper (USB pair, USB-C fan-out, eFuse and
regulator connections, LED data line, +5V feeds), ground/thermal vias,
LED capacitor links. Every item is checked against all other copper.
python3 route_board.py auto Stage R2: Freerouting for everything else (needs Java + freerouting jar,
path in $FREEROUTING_JAR). GND and +5V are left to the copper planes.
python3 route_board.py zones Stage R3: ground plane (back), +5V / GND fills (front), fill.
Layer plan (2 layers, 1.6 mm):
back (B.Cu) : solid GND plane everywhere except the antenna keep-outs; only short, necessary jumps.
front (F.Cu) : parts, signals, +5V fill over the LED area and the right-hand strip,
GND fill in the USB / ESP32 strip.
"""
import math
import os
import sys
import pcbnew
from shapely.geometry import LineString, Point, Polygon, box
from shapely.affinity import rotate, translate
from shapely.strtree import STRtree
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import make_board as mb # noqa: E402
MM = pcbnew.FromMM
TO = pcbnew.ToMM
F, B = pcbnew.F_Cu, pcbnew.B_Cu
POWER = {"GND", "+5V", "VBUS"}
VIA_D, VIA_DRILL = 0.6, 0.3
EDGE_CLR = 0.5
def P(x, y):
return mb.pt(x, y)
def xy(v):
return (TO(v.x) - mb.X0, TO(v.y) - mb.Y0)
def short(net):
return str(net).split("/")[-1]
def clearance(n1, n2):
return 0.25 if (short(n1) in POWER or short(n2) in POWER) else 0.2
# ---- copper model ---------------------------------------------------------------------------
class Copper:
"""Shapely model of all copper, holes and keep-outs, to check new items before adding them."""
def __init__(self, board):
self.b = board
self.items = [] # (geom, net, layers, kind)
self.nets = {}
for f in board.GetFootprints():
for p in f.Pads():
self.add_pad(f, p)
for t in board.GetTracks():
self.add_track_obj(t)
self.keepouts = []
for z in board.Zones():
if z.GetIsRuleArea() and (z.GetDoNotAllowTracks() or z.GetDoNotAllowVias()):
self.keepouts.append((self.zone_poly(z), self.layers_of(z)))
for f in board.GetFootprints():
for z in f.Zones():
if z.GetIsRuleArea():
self.keepouts.append((self.zone_poly(z), self.layers_of(z)))
self.inner = box(EDGE_CLR, EDGE_CLR, mb.W - EDGE_CLR, mb.H - EDGE_CLR)
self._tree = None
@staticmethod
def layers_of(z):
s = set()
if z.GetLayerSet().Contains(F):
s.add("F")
if z.GetLayerSet().Contains(B):
s.add("B")
return s
@staticmethod
def zone_poly(z):
o = z.Outline().Outline(0)
pts = [xy(o.CPoint(i)) for i in range(o.PointCount())]
return Polygon(pts)
def add_pad(self, f, p):
cx, cy = xy(p.GetPosition())
sx, sy = TO(p.GetSize().x), TO(p.GetSize().y)
ang = p.GetOrientationDegrees()
shape = p.GetShape()
if shape == pcbnew.PAD_SHAPE_CIRCLE:
g = Point(cx, cy).buffer(sx / 2, 16)
elif shape == pcbnew.PAD_SHAPE_OVAL:
r = min(sx, sy) / 2
L = max(sx, sy) / 2 - r
seg = LineString([(-L, 0), (L, 0)]) if sx >= sy else LineString([(0, -L), (0, L)])
g = translate(rotate(seg.buffer(r, 16), -ang, origin=(0, 0)), cx, cy)
else:
g = translate(rotate(box(-sx / 2, -sy / 2, sx / 2, sy / 2), -ang, origin=(0, 0)), cx, cy)
attr = p.GetAttribute()
net = p.GetNetname()
if attr == pcbnew.PAD_ATTRIB_NPTH:
d = TO(p.GetDrillSize().x) / 2
self.items.append((Point(cx, cy).buffer(d, 16), "", {"F", "B"}, "hole"))
return
layers = {"F", "B"} if attr == pcbnew.PAD_ATTRIB_PTH else {"F"}
if not (p.IsOnLayer(F) or p.IsOnLayer(B)):
return # paste-only aperture, no copper
self.items.append((g, net, layers, "pad"))
def add_track_obj(self, t):
net = t.GetNetname()
if isinstance(t, pcbnew.PCB_VIA):
self.items.append((Point(*xy(t.GetPosition())).buffer(TO(t.GetWidth()) / 2, 16), net, {"F", "B"}, "via"))
else:
ly = "F" if t.GetLayer() == F else "B"
g = LineString([xy(t.GetStart()), xy(t.GetEnd())]).buffer(TO(t.GetWidth()) / 2, 8)
self.items.append((g, net, {ly}, "track"))
self._tree = None
def tree(self):
if self._tree is None:
self._tree = STRtree([i[0] for i in self.items])
return self._tree
def conflicts(self, geom, net, layers, extra=0.0):
"""Items of other nets closer than the clearance, plus keep-out / edge problems."""
out = []
for idx in self.tree().query(geom.buffer(0.3)):
g, n, ly, kind = self.items[idx]
if not (ly & layers):
continue
if kind == "hole":
if geom.distance(g) < 0.25 + extra:
out.append("hole")
continue
if n == net and n != "":
continue
dd = geom.distance(g)
if dd < clearance(net, n) + extra - 1e-6:
out.append(f"{short(n) or kind}({dd:.2f})")
for poly, ly in self.keepouts:
if (ly & layers) and geom.intersects(poly):
out.append("keep-out")
if not self.inner.contains(geom):
out.append("board edge")
return out
# ---- builder ---------------------------------------------------------------------------------
class Router:
def __init__(self, board):
self.b = board
self.cu = Copper(board)
self.netinfo = {}
for name, ni in board.GetNetsByName().items():
name = str(name)
if name:
self.netinfo[short(name)] = ni
self.netinfo[name] = ni
self.problems = []
self.count = {"track": 0, "via": 0}
self.feed = None
def net(self, name):
n = self.netinfo.get(name)
if n is None:
raise KeyError(name)
return n
def track(self, pts, width, netname, layer=F, check=True, label=""):
net = self.net(netname)
pts = [p for i, p in enumerate(pts) if i == 0 or math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]) > 1e-4]
if len(pts) < 2:
return True
ly = "F" if layer == F else "B"
ok = True
for a, b_ in zip(pts, pts[1:]):
g = LineString([a, b_]).buffer(width / 2, 8)
bad = self.cu.conflicts(g, net.GetNetname(), {ly}) if check else []
if bad:
ok = False
self.problems.append(f"{label or netname}: segment {a}->{b_} {ly} too close to {sorted(set(bad))}")
if not ok:
return False
for a, b_ in zip(pts, pts[1:]):
t = pcbnew.PCB_TRACK(self.b)
t.SetStart(P(*a))
t.SetEnd(P(*b_))
t.SetWidth(MM(width))
t.SetLayer(layer)
t.SetNet(net)
t.SetLocked(True)
self.b.Add(t)
self.cu.add_track_obj(t)
self.count["track"] += 1
return True
def via_ok(self, x, y, netname):
net = self.net(netname).GetNetname()
g = Point(x, y).buffer(VIA_D / 2, 16)
bad = self.cu.conflicts(g, net, {"F", "B"})
# drilled holes need 0.25 mm between them (JLCPCB hole-to-hole)
for idx in self.cu.tree().query(Point(x, y).buffer(1.0)):
gg, n, ly, kind = self.cu.items[idx]
if kind in ("via", "hole") and Point(x, y).distance(gg.centroid) < VIA_D / 2 + 0.25 + 0.15 and n != net:
bad.append("hole spacing")
if kind == "via" and n == net and Point(x, y).distance(gg.centroid) < VIA_D + 0.2:
bad.append("via spacing")
return bad
def via(self, x, y, netname, check=True, label=""):
bad = self.via_ok(x, y, netname) if check else []
if bad:
self.problems.append(f"{label or netname}: via at ({x:.2f},{y:.2f}) too close to {sorted(set(bad))}")
return False
v = pcbnew.PCB_VIA(self.b)
v.SetPosition(P(x, y))
v.SetWidth(MM(VIA_D))
v.SetDrill(MM(VIA_DRILL))
v.SetNet(self.net(netname))
v.SetLocked(True)
self.b.Add(v)
self.cu.add_track_obj(v)
self.count["via"] += 1
return True
def fps_by_ref(board):
return {f.GetReference(): f for f in board.GetFootprints()}
def pad_of(f, num):
return [p for p in f.Pads() if p.GetNumber() == num]
def padxy(fps, ref, num):
return xy(pad_of(fps[ref], num)[0].GetPosition())
def chamfer(pts, c=0.5):
"""Replace each 90-degree corner of an axis-aligned polyline with a 45-degree chamfer."""
out = [pts[0]]
for i in range(1, len(pts) - 1):
(ax, ay), (bx, by), (cx, cy) = pts[i - 1], pts[i], pts[i + 1]
d1 = math.hypot(bx - ax, by - ay)
d2 = math.hypot(cx - bx, cy - by)
k = min(c, d1 / 2, d2 / 2)
if k < 1e-6:
out.append(pts[i])
continue
out.append((bx - (bx - ax) / d1 * k, by - (by - ay) / d1 * k))
out.append((bx + (cx - bx) / d2 * k, by + (cy - by) / d2 * k))
out.append(pts[-1])
return out
# ---- stage R1 ----------------------------------------------------------------------------------
def stage_pre(board):
for t in list(board.GetTracks()):
board.Remove(t)
R = Router(board)
fps = fps_by_ref(board)
USB = 0.30
# --- USB-C fan-out: D+ pads (A6, B6) joined on top above the pads; D- (A7) reaches B7 through two vias
# just below the pads (under the connector body), then D- and D+ go straight up into U1.
R.track([(89.75, 61.9), (89.75, 61.05), (90.75, 61.05), (90.75, 61.9)], USB, "USB_DP", label="J1 D+ join")
R.track([(90.75, 61.05), (91.15, 60.65), (91.15, 59.6)], USB, "USB_DP", label="J1 D+ to U1.6")
R.track([(89.25, 61.9), (89.25, 59.6)], USB, "USB_DN", label="J1 D- to U1.4")
R.track([(90.25, 62.7), (90.25, 63.55)], USB, "USB_DN", label="J1 A7 stub")
R.track([(89.25, 62.7), (89.25, 63.55)], USB, "USB_DN", label="J1 B7 stub")
R.via(90.25, 63.55, "USB_DN", label="J1 A7")
R.via(89.25, 63.55, "USB_DN", label="J1 B7")
R.track([(89.25, 63.55), (90.25, 63.55)], USB, "USB_DN", layer=B, label="J1 D- jumper")
# USB-C ground pads to the shield (plated tabs reach the back ground plane)
R.track([(86.75, 62.6), (85.68, 62.6)], 0.4, "GND", label="J1 A1 to shield")
R.track([(93.25, 62.6), (94.32, 62.6)], 0.4, "GND", label="J1 A12 to shield")
# U1 VBUS pin (sense only) and GND pin
R.track([(90.2, 59.8), (90.2, 60.33)], 0.3, "VBUS", label="U1.5")
R.via(90.2, 60.33, "VBUS", label="U1.5")
R.track([(90.2, 56.6), (90.2, 56.0)], 0.4, "GND", label="U1.2")
R.via(90.2, 56.0, "GND", label="U1.2")
# --- USB pair from U1 to the ESP32. Front: west above the buttons' row, down beside the connector,
# west below the buttons; back: under the ESP32 (4 vias in total); front: into R14/R15 -> pins 27/26.
dp_top = chamfer([(91.15, 56.7), (91.15, 55.25), (84.2, 55.25), (84.2, 62.4), (48.0, 62.4)])
dn_top = chamfer([(89.25, 56.7), (89.25, 55.75), (84.7, 55.75), (84.7, 62.9), (49.8, 62.9), (49.3, 63.4),
(47.3, 63.4)])
R.track(dp_top, USB, "USB_DP", label="USB D+ front")
R.track(dn_top, USB, "USB_DN", label="USB D- front")
R.via(48.0, 62.4, "USB_DP", label="USB D+ dive")
R.via(47.3, 63.4, "USB_DN", label="USB D- dive")
R.track(chamfer([(48.0, 62.4), (30.3, 62.4), (30.3, 57.6)]), USB, "USB_DP", layer=B, label="USB D+ back")
R.track(chamfer([(47.3, 63.4), (46.8, 62.9), (29.6, 62.9), (29.6, 58.3)]), USB, "USB_DN", layer=B,
label="USB D- back")
R.via(30.3, 57.6, "USB_DP", label="USB D+ up")
R.via(29.6, 58.3, "USB_DN", label="USB D- up")
R.track([(30.3, 57.6), (30.59, 57.2), (30.59, 56.6)], USB, "USB_DP", label="D+ to R14")
R.track(chamfer([(29.6, 58.3), (29.6, 55.4), (30.59, 55.4)], 0.4), USB, "USB_DN", label="D- to R15")
R.track([(31.61, 56.6), (34.1, 56.6)], USB, "USB_DP_MCU", label="R14 to U4.27")
R.track([(32.6, 56.6), (32.6, 57.2)], 0.25, "USB_DP_MCU", label="C13 spot")
R.track([(31.61, 55.4), (32.0, 55.4), (32.4, 55.8), (34.1, 55.8)], USB, "USB_DN_MCU", label="R15 to U4.26")
R.track([(32.6, 55.8), (32.6, 54.8)], 0.25, "USB_DN_MCU", label="C14 spot")
# --- LED data (3.3 V) from ESP32 pin 16 to the level shifter at the left edge. It cannot cross the
# button / boot lines on the front, so it dives under the module edge for one short back-layer jump,
# comes up under LED18's body (between its pads), runs west under LED17 and up the left margin.
R.track([(41.6, 54.3), (41.6, 55.3)], 0.25, "LED_DATA_3V3", label="U4.16 stub")
R.via(41.6, 55.3, "LED_DATA_3V3", label="LED data dive")
R.track([(41.6, 55.3), (41.6, 53.2), (38.0, 49.8)], 0.25, "LED_DATA_3V3", layer=B, label="LED data back")
R.via(38.0, 49.8, "LED_DATA_3V3", label="LED data up (under LED18)")
R.track(chamfer([(38.0, 49.8), (3.2, 49.8), (3.2, 19.2), (4.4, 19.2), (4.4, 18.4)], 0.4), 0.25,
"LED_DATA_3V3", label="LED data front")
R.track([(3.2, 23.11), (4.4, 23.11)], 0.25, "LED_DATA_3V3", label="R12 branch")
# 5 V side of the level shifter -> 33 R -> LED1 / TP6
R.track([(5.35, 15.2), (5.35, 14.2), (4.4, 13.25), (4.4, 12.7)], 0.25, "LED_DATA_5V", label="U5 to R13")
R.track([(4.4, 11.5), (4.4, 9.3), (7.8, 9.33)], 0.25, "LED_DIN", label="R13 to TP6 / LED1")
# --- VBUS: connector (right pads) -> TVS -> IN capacitor -> eFuse IN pins; EN divider and TP1 on it
R.track([(92.45, 61.8), (92.45, 60.8), (93.2, 60.8)], 0.5, "VBUS", label="J1 VBUS (right)")
R.track([(93.2, 60.8), (94.3, 60.8)], 0.8, "VBUS", label="VBUS past J1 GND pad")
R.track([(94.3, 60.8), (101.0, 60.8), (102.9, 62.7), (102.9, 63.9)], 1.2, "VBUS", label="VBUS to D1 (1.2 mm)")
R.track([(103.9, 63.8), (105.5, 63.6)], 0.8, "VBUS", label="D1 to C1")
R.track([(105.5, 63.5), (106.95, 63.5)], 0.6, "VBUS", label="C1 to U2 IN")
R.track([(98.8, 60.8), (98.8, 59.2)], 0.5, "VBUS", label="TP1")
R.track([(101.0, 60.8), (101.8, 60.0), (105.3, 60.0)], 0.4, "VBUS", label="R4 top")
# --- eFuse pins at 0.5 mm pitch: short hand-routed links to their support parts (TI 11.1)
R.track([(106.95, 62.25), (106.9, 61.3)], 0.25, "DVDT", label="U2.1 to C2")
R.track([(106.85, 62.75), (106.2, 62.75), (105.75, 62.3)], 0.25, "EN_UVLO", label="U2.2 to R5")
R.track([(105.5, 61.2), (105.65, 61.85)], 0.25, "EN_UVLO", label="R4 to R5")
R.track([(109.05, 62.75), (109.5, 62.75), (109.8, 62.45), (109.8, 61.4)], 0.25, "ILM", label="U2.7 to R3")
R.track([(109.05, 63.25), (110.8, 63.25), (111.3, 62.75), (111.3, 61.4)], 0.25, "~{FLT}", label="U2.6 to R6")
# --- U1: its paired pins are joined inside the chip; the same link is added under the body in copper
R.track([(89.25, 57.5), (89.25, 58.9)], 0.3, "USB_DN", label="U1.3-U1.4")
R.track([(91.15, 57.5), (91.15, 58.9)], 0.3, "USB_DP", label="U1.1-U1.6")
# --- eFuse: GND pin 8 into the exposed pad. Ground/thermal vias sit just OUTSIDE the pad (tented, no
# open holes in the pad that could pull solder away), joined to the pad by short tracks
R.track([(108.85, 62.25), (108.25, 62.25)], 0.25, "GND", label="U2.8 to pad")
R.track([(108.0, 62.3), (108.0, 61.5)], 0.4, "GND", label="U2 pad to via (top)")
R.via(108.0, 61.5, "GND", label="U2 thermal via (top)")
R.track([(108.0, 63.7), (108.0, 64.5)], 0.4, "GND", label="U2 pad to via (bottom)")
R.via(108.0, 64.5, "GND", label="U2 thermal via (bottom)")
# OUT (single pin) -> wide +5V copper into the +5V fill on the right-hand strip
R.track([(108.95, 63.75), (109.5, 63.75), (109.95, 64.2)], 0.25, "+5V", label="U2 OUT pin")
R.track([(109.95, 64.35), (113.0, 64.35)], 1.2, "+5V", label="U2 OUT (1.2 mm)")
# --- 3.3 V regulator: GND pins into the exposed pad, two thermal vias; +5V pins joined to C7
R.track([(52.7, 61.15), (53.2, 61.15)], 0.25, "GND", label="U3.3 to pad")
R.track([(54.3, 60.5), (53.8, 60.5)], 0.25, "GND", label="U3.5 to pad")
R.track([(53.5, 59.8), (53.5, 59.1)], 0.4, "GND", label="U3 pad to via")
R.via(53.5, 59.1, "GND", label="U3 thermal via (outside the pad)")
R.track([(54.45, 59.85), (55.2, 59.85)], 0.3, "+5V", label="U3.6")
R.track([(54.45, 61.15), (55.2, 61.15)], 0.3, "+5V", label="U3.4")
R.track([(55.2, 59.85), (55.2, 61.45), (57.5, 61.45)], 0.5, "+5V", label="U3 IN to C7")
R.track([(52.55, 59.85), (52.55, 60.5)], 0.3, "+3V3", label="U3 OUT pins")
# --- C301 (left bulk capacitor) up into the +5V fill
R.track([(10.8, 57.6), (10.8, 53.6)], 1.0, "+5V", label="C301 +5V")
# --- ESP32 exposed pad: six tented ground vias under the module, in the free bands left and right of the
# centre pad (not inside it), each joined to the nearest pad section by a short track
for y in (57.03, 59.0, 60.97):
R.via(35.9, y, "GND", label="U4 ground via (left)")
R.track([(36.2, y), (37.5, y)], 0.4, "GND", label="U4 via to pad (left)")
R.via(44.0, y, "GND", label="U4 ground via (right)")
R.track([(43.7, y), (42.5, y)], 0.4, "GND", label="U4 via to pad (right)")
strip_routes(R)
gnd_fanout(R, fps)
led_cap_links(R, fps)
print("stage R1: %d track segments, %d vias" % (R.count["track"], R.count["via"]))
if R.problems:
print("PROBLEMS (%d):" % len(R.problems))
for p in R.problems:
print(" " + p)
return R
def strip_routes(R):
"""ESP32 / button / USB-C strip, routed by hand as parallel front-layer lanes.
The ESP32's top-edge pins run BOOT, IO8, ON/OFF (west) then CC2, CC1 (east). The CC lines must end at
the USB-C connector, furthest east, so they take one short back-layer jump each right at the module edge
and continue as the northern lanes. The other lanes then end one after another from the south:
EN at RESET, ON/OFF at its button (+ R11), IO8 at R9, BOOT at its button (+ R8). 3.3 V for the
pull-ups runs under the buttons. Other deliberate back-layer jumps: 3.3 V to the eFuse FLT pull-up,
+5V down to the regulator, and CC1 / VBUS-left under the USB-C fan-out (both pads sit inside the USB pair).
"""
W = 0.25
D = 0.19 # parallel 45-degree jogs: each lower lane starts its diagonal this much earlier (0.2 mm gap)
# jog 1 just east of the module corner (BOOT, IO8, ON/OFF drop 0.95 mm), jog 2 before the letter I
# (all five lanes drop 1.65 mm to pass under LED19/LED20's capacitors)
def lane(x0, y0, k, extra):
a1 = 47.6 - k * D
a2 = 56.0 - (k + 2) * D
return [(x0, 54.1), (x0, y0), (a1, y0), (a1 + 0.95, y0 + 0.95), (a2, y0 + 0.95),
(a2 + 1.65, y0 + 2.6)] + extra
R.track(lane(36.0, 52.4, 0, [(76.9, 55.0), (79.95, 55.0)]), W, "BOOT", label="BOOT lane")
R.track([(76.9, 55.0), (77.3, 55.4), (77.3, 57.32)], W, "BOOT", label="BOOT to its button")
R.track([(80.3, 55.0), (82.9, 55.0), (83.3, 55.4), (83.3, 57.32)], W, "BOOT", label="BOOT to button (2nd pad)")
R.track(lane(36.8, 52.85, 1, [(75.2, 55.45), (75.8, 56.05), (75.8, 56.6)]), W, "IO8_STRAP", label="IO8 lane")
R.track(lane(39.2, 53.3, 2, [(66.2, 55.9), (66.8, 56.5)]), W, "BTN_ONOFF", label="ON/OFF lane")
R.track([(66.8, 56.79), (67.8, 57.32), (68.3, 57.32)], W, "BTN_ONOFF", label="R11 to ON/OFF button")
R.track([(68.3, 57.32), (74.3, 57.32)], W, "BTN_ONOFF", label="ON/OFF button pads")
# --- CC lines: jump under the module edge, then the two northern lanes to the USB-C side
for x, ytop in ((44.0, 51.75), (44.8, 51.05)):
net = "CC2" if x == 44.0 else "CC1"
R.track([(x, 54.3), (x, 55.3)], W, net, label=net + " stub")
R.via(x, 55.3, net, label=net + " down")
R.track([(x, 55.3), (x, ytop)], W, net, layer=B, label=net + " jump")
R.via(x, ytop, net, label=net + " up")
a2 = 56.0
R.track([(44.0, 51.75), (48.0, 51.75), (49.15, 52.9), (a2 - D, 52.9), (a2 - D + 1.65, 54.55), (77.69, 54.55),
(78.59, 53.65), (91.42, 53.65), (91.82, 54.05), (91.82, 60.9), (91.75, 61.8)], W, "CC2", label="CC2 lane")
R.track([(91.82, 57.6), (92.2, 57.6)], W, "CC2", label="R2")
R.track([(44.8, 51.05), (48.4, 51.05), (49.8, 52.45), (a2, 52.45), (a2 + 1.65, 54.1), (77.5, 54.1),
(78.4, 53.2), (94.05, 53.2), (94.45, 53.6), (94.45, 58.75), (93.8, 59.4)], W, "CC1", label="CC1 lane")
R.track([(94.45, 57.71), (95.1, 57.71)], W, "CC1", label="R1")
R.via(93.8, 59.4, "CC1", label="CC1 down (USB-C side)")
R.track([(93.8, 59.4), (88.8, 59.4), (88.4, 59.8), (88.4, 60.8)], W, "CC1", layer=B, label="CC1 under fan-out")
R.via(88.4, 60.8, "CC1", label="CC1 up at A5")
R.track([(88.4, 60.8), (88.75, 61.3), (88.75, 61.8)], W, "CC1", label="CC1 to J1 A5")
# --- VBUS left pad (A4/B9) joins U1's VBUS via and the main VBUS track through the back layer
R.track([(87.55, 61.8), (87.5, 61.05)], 0.3, "VBUS", label="J1 A4 to via")
R.via(87.5, 61.05, "VBUS", label="VBUS-left")
R.track([(87.5, 61.05), (87.5, 61.9), (90.2, 61.9), (90.2, 60.33), (93.6, 60.33), (93.6, 60.8)], 0.6, "VBUS",
layer=B, label="VBUS-left back")
R.via(93.6, 60.8, "VBUS", label="VBUS-left joins main")
# --- EN: pin 8 -> C11 -> R7 -> lane south of the others -> RESET button
R.track([(46.1, 57.4), (47.3, 57.4), (47.8, 57.9)], W, "EN", label="EN pin to C11")
R.track(chamfer([(47.8, 57.9), (47.8, 55.0)], 0.3) + [(55.1, 55.0), (57.0, 56.9), (58.9, 56.9), (59.3, 57.3)], W, "EN",
label="EN lane")
R.track([(59.3, 57.32), (65.3, 57.32)], W, "EN", label="RESET button pads")
# --- 3.3 V: regulator / module side, and the trunk under the buttons with risers to the pull-ups
R.track([(46.3, 61.4), (47.82, 61.4)], 0.4, "+3V3", label="U4.3 to C10")
R.track([(47.8, 61.1), (47.8, 60.1)], 0.4, "+3V3", label="C10 to R10")
R.track([(47.8, 60.6), (50.6, 60.6), (50.6, 60.9)], 0.4, "+3V3", label="module 3V3 to C8")
R.track([(51.3, 60.85), (51.9, 60.5), (52.55, 60.5)], 0.3, "+3V3", label="C8 to U3 OUT")
R.track([(49.7, 57.45), (49.0, 56.75), (48.81, 56.5)], W, "+3V3", label="C9 to R7")
R.track([(50.3, 58.2), (57.2, 58.2), (57.55, 58.55), (80.3, 58.55)], 0.4, "+3V3", label="3V3 trunk")
R.track([(52.55, 58.2), (52.55, 59.7)], 0.4, "+3V3", label="U3 OUT to trunk")
R.track([(53.8, 57.0), (53.8, 58.2)], 0.4, "+3V3", label="TP3")
R.track([(66.8, 58.1), (66.8, 58.55)], 0.3, "+3V3", label="R11 riser")
R.track([(75.8, 58.1), (75.8, 58.55)], 0.3, "+3V3", label="R9 riser")
R.track([(80.3, 58.55), (80.3, 56.1)], 0.3, "+3V3", label="R8 riser")
# 3.3 V to the eFuse FLT pull-up R6: one jump under the CC lanes, then north of everything
R.track([(80.6, 55.93), (81.8, 55.95)], 0.3, "+3V3", label="R8 to FLT feed")
R.via(81.8, 55.95, "+3V3", label="3V3 jump down")
R.track([(81.8, 55.95), (81.8, 52.4)], 0.3, "+3V3", layer=B, label="3V3 jump")
R.via(81.8, 52.4, "+3V3", label="3V3 jump up")
R.track(chamfer([(81.8, 52.4), (103.0, 52.4), (105.3, 54.7), (111.3, 54.7), (111.3, 60.0)], 0.4), 0.3, "+3V3",
label="3V3 to R6")
# --- ESP32 corner ground pad enclosed by EN and the lanes: its own via to the back plane
R.via(46.9, 55.6, "GND", label="U4 corner GND via")
R.track([(45.95, 54.05), (45.95, 55.0), (46.3, 55.0), (46.9, 55.6)], 0.3, "GND", label="U4 pads 52/11 to via")
# --- level-shifter capacitor: short +5V stub into the fill (one thermal spoke only otherwise)
R.track([(4.4, 20.88), (5.6, 20.88)], 0.4, "+5V", label="C12 +5V stub")
# --- +5V for the regulator: one jump from the +5V fill (above the lanes) down to U3 / C7
R.via(54.0, 51.6, "+5V", label="+5V feed (fill side)")
R.track([(54.0, 51.6), (55.9, 59.3)], 0.6, "+5V", layer=B, label="+5V feed jump")
R.via(55.9, 59.3, "+5V", label="+5V feed (regulator side)")
R.track([(55.9, 59.3), (55.2, 60.0)], 0.5, "+5V", label="+5V feed to U3")
def gnd_fanout(R, fps):
"""A via next to every front-side GND pad (short 0.4 mm link), placed where it blocks nothing."""
skip_refs = {"J1", "U2", "U3", "U4", "U1"}
missing = []
for ref, f in sorted(fps.items()):
if ref in skip_refs or ref.startswith("H"):
continue
for p in f.Pads():
if short(p.GetNetname()) != "GND" or p.GetAttribute() == pcbnew.PAD_ATTRIB_PTH:
continue
cx, cy = xy(p.GetPosition())
sx, sy = TO(p.GetBoundingBox().GetWidth()), TO(p.GetBoundingBox().GetHeight())
fx, fy = xy(f.GetPosition())
n_vias = 2 if max(sx, sy) >= 2.4 else 1
cands = []
if ref.startswith("LED"):
# under the LED body, towards the centre, beside the GND pad
ux, uy = (fx - cx), (fy - cy)
L = math.hypot(ux, uy)
ux, uy = ux / L, uy / L
for t in (1.25, 1.45, 1.7):
for s in (0.0, 0.35, -0.35, 0.7, -0.7):
cands.append((cx + ux * t - uy * s, cy + uy * t + ux * s))
for r in (0.0, 0.25, 0.5, 0.8, 1.1):
for k in range(16):
a = 2 * math.pi * k / 16
rx = sx / 2 + 0.35 + r
ry = sy / 2 + 0.35 + r
cands.append((cx + rx * math.cos(a), cy + ry * math.sin(a)))
placed = 0
pad_geo = [g for (g, n, ly, kind) in R.cu.items if kind == "pad" and "F" in ly and g.distance(Point(cx, cy)) < 3]
for (vx, vy) in cands:
if placed >= n_vias:
break
# tented via: its drilled hole stays >= 0.25 mm from every pad's solder-mask opening
# (0.15 mm hole radius + 0.1 mm mask web), so no open hole can pull solder from a pad
if any(g.distance(Point(vx, vy)) < 0.25 for g in pad_geo):
continue
if R.via_ok(vx, vy, "GND"):
continue
link = LineString([(cx, cy), (vx, vy)]).buffer(0.2, 8)
if R.cu.conflicts(link, R.net("GND").GetNetname(), {"F"}):
continue
R.via(vx, vy, "GND", check=False)
R.track([(cx, cy), (vx, vy)], 0.4, "GND", check=False)
placed += 1
if placed == 0:
missing.append(f"{ref}.{p.GetNumber()}")
if missing:
R.problems.append("GND pads without a via (will rely on the front GND/+5V fills): " + ", ".join(missing))
def led_cap_links(R, fps):
"""Direct +5V link from each LED's VDD pad to its own 100 nF capacitor."""
failed = []
for n in range(1, 108):
a = padxy(fps, f"LED{n}", "1")
c = padxy(fps, f"C{100 + n}", "1")
options = [[a, c], [a, (c[0], a[1]), c], [a, (a[0], c[1]), c]]
for pts in options:
ok = True
for s, e in zip(pts, pts[1:]):
if R.cu.conflicts(LineString([s, e]).buffer(0.2, 8), R.net("+5V").GetNetname(), {"F"}):
ok = False
if ok:
R.track(pts, 0.4, "+5V", check=False)
break
else:
failed.append(f"LED{n}")
if failed:
R.problems.append("LED capacitors without a direct track (joined by the +5V fill): " + ", ".join(failed))
# ---- stage R2: Freerouting -----------------------------------------------------------------------
FR_JAR = os.environ.get("FREEROUTING_JAR", "")
PLANE_NETS = {"GND"} # left entirely to the copper planes
U3_FEED = set() # the regulator feed is hand-routed now: all +5V copper is a fixed obstacle
def add_keepout(board, rects, layers, name):
for (x0, y0, x1, y1) in rects:
z = pcbnew.ZONE(board)
z.SetIsRuleArea(True)
z.SetZoneName(name)
ls = pcbnew.LSET()
for ly in layers:
ls.AddLayer(ly)
z.SetLayerSet(ls)
z.SetDoNotAllowTracks(True)
z.SetDoNotAllowVias(True)
z.SetDoNotAllowCopperPour(False)
z.SetDoNotAllowPads(False)
z.SetDoNotAllowFootprints(False)
o = z.Outline()
o.NewOutline()
for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]:
p = mb.pt(x, y)
o.Append(p.x, p.y)
board.Add(z)
# temporary routing keep-outs (removed again after Freerouting)
BACK_KEEPOUT = [ # back layer: under the front USB pair run, under the ESP32, along the back USB pair
(83.4, 54.4, 92.0, 56.6), (83.4, 54.4, 85.5, 63.8), (46.5, 61.6, 85.5, 63.8),
(33.2, 53.2, 46.8, 64.6), (28.8, 56.8, 33.2, 63.8)]
FRONT_KEEPOUT = [(34.9, 54.9, 45.1, 64.6), # front layer under the ESP32 body (inside its pad ring)
(0.6, 52.4, 32.5, 53.8), (47.0, 52.4, 111.5, 53.8)] # bottom edge of the +5V fill
DSN_CLASSES = [ # (class, width um, nets)
("usb", 300, ["USB_DP", "USB_DN", "/ESP32-C3 controller + buttons/USB_DP_MCU",
"/ESP32-C3 controller + buttons/USB_DN_MCU"]),
("power", 500, ["VBUS", "+5V"]),
("supply3v3", 400, ["+3V3"]),
]
DSN_SETTINGS = """ (autoroute_settings
(fanout off) (autoroute on) (postroute on) (vias on)
(via_costs 120) (plane_via_costs 5) (start_ripup_costs 100) (start_pass_no 1)
(layer_rule F.Cu (active on) (preferred_direction horizontal)
(preferred_direction_trace_costs 1.0) (against_preferred_direction_trace_costs 1.2))
(layer_rule B.Cu (active on) (preferred_direction vertical)
(preferred_direction_trace_costs 6.0) (against_preferred_direction_trace_costs 8.0))
)
"""
def fix_dsn(txt):
"""KiCad 7's export puts every net in one class: add width classes, 0.25 mm clearance everywhere,
and Freerouting settings that make back-layer copper and vias expensive."""
import re
def q(n):
return '"%s"' % n if re.search(r'[\s()/"~{}+-]', n) and not n.startswith('"') else n
# declare the one-item "FIXED_n" nets (KiCad only writes nets that have pads)
fixed = sorted(set(re.findall(r"\(net (FIXED_\d+)\)", txt)), key=lambda n: int(n.split("_")[1]))
first_class = txt.index("(class kicad_default")
txt = txt[:first_class] + "".join("(net %s (pins))\n " % n for n in fixed) + txt[first_class:]
a = txt.index("(class kicad_default")
b = txt.index("(circuit", a)
head = txt[a:b]
extra = ""
for name, w, nets in DSN_CLASSES:
for n in nets:
for form in ('"%s"' % n, n):
head = re.sub(r"(?<=[\s])" + re.escape(form) + r"(?=[\s)])", " ", head)
extra += (" (class %s %s\n (circuit (use_via Via[0-1]_600:300_um))\n"
" (rule (width %d) (clearance 250))\n )\n") % (name, " ".join(q(n) for n in nets), w)
txt = txt[:a] + head + txt[b:]
txt = re.sub(r"\(width 200\)", "(width 250)", txt)
txt = re.sub(r"\(clearance ([0-9.]+)\)", lambda m: "(clearance %s)" % max(float(m.group(1)), 250.0), txt)
# new classes go right after the default class (inside the network scope)
end_default = txt.index("(wiring")
close = txt.rindex(")", 0, end_default) # closes (network
txt = txt[:close] + extra + txt[close:]
# (an autoroute_settings block makes Freerouting 2.1 route nothing, so back-layer use is limited with
# keep-outs instead and reviewed afterwards)
return txt
def stage_auto(board, work):
import re
import subprocess
import sexp
orphan = board.FindNet(0)
fps = fps_by_ref(board)
# routing copy: plane nets become plain obstacles; +5V only kept on the regulator feed group
feed_items = 0
for f in board.GetFootprints():
for p in f.Pads():
n = short(p.GetNetname())
if n in PLANE_NETS or (n == "+5V" and (f.GetReference(), p.GetNumber()) not in U3_FEED):
p.SetNet(orphan)
feed_pts = [(55.2, 59.85), (55.2, 61.45), (57.5, 61.45), (54.45, 59.85), (54.45, 61.15), (55.2, 61.15)]
dummy = [0]
def park(t):
"""Freerouting ignores copper without a net: give each plane-net track/via its own one-item net so it
stays a fixed obstacle but needs no routing."""
dummy[0] += 1
ni = pcbnew.NETINFO_ITEM(board, "FIXED_%d" % dummy[0])
board.Add(ni)
t.SetNet(ni)
for t in list(board.GetTracks()):
n = short(t.GetNetname())
if n in PLANE_NETS:
park(t)
elif n == "+5V":
keep = False
if isinstance(t, pcbnew.PCB_VIA):
x, y = xy(t.GetPosition())
keep = False
else:
a, b_ = xy(t.GetStart()), xy(t.GetEnd())
keep = False
if keep:
feed_items += 1
else:
park(t)
add_keepout(board, BACK_KEEPOUT, [B], "route keep-out back")
add_keepout(board, FRONT_KEEPOUT, [F], "route keep-out front")
dsn = os.path.join(work, "board.dsn")
ses = os.path.join(work, "board.ses")
pcbnew.ExportSpecctraDSN(board, dsn)
txt = open(dsn).read()
txt = fix_dsn(txt)
open(dsn, "w").write(txt)
print("DSN written:", dsn, "| +5V feed items kept:", feed_items)
passes = os.environ.get("FR_PASSES", "30")
# small heap: the container shares ~1 GB free memory with the editor
cmd = ["java", "-Xmx600m", "-XX:MaxMetaspaceSize=128m", "-Xss512k", "-XX:+UseSerialGC", "-jar", FR_JAR,
"-de", dsn, "-do", ses, "-mp", passes, "-mt", "1"]
print("running:", " ".join(cmd))
with open(os.path.join(work, "freerouting.log"), "w") as log:
env = dict(os.environ, DISPLAY=os.environ.get("DISPLAY", ":97")) # Freerouting 1.9 needs a display
subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=work, # cwd: keeps its logs/ out of the project
timeout=int(os.environ.get("FR_TIMEOUT", "3000")))
return ses
HAND_NETS = {"CC1", "CC2", "BOOT", "IO8_STRAP", "BTN_ONOFF", "EN", "+3V3", "VBUS", "USB_DP", "USB_DN", "USB_DP_MCU",
"USB_DN_MCU", "LED_DATA_3V3", "LED_DATA_5V", "LED_DIN", "DVDT", "EN_UVLO", "+5V", "GND"}
def import_ses(board, ses):
"""Add Freerouting's new wires and vias (skips the pre-routed, protected ones and the nets routed by hand
in stage R1, where Freerouting only adds stubs to pad centres)."""
import sexp
tree = sexp.parse(open(ses).read())
routes = sexp.find1(tree, "routes")
res = sexp.find1(routes, "resolution")
scale = 1000.0 / float(res[2]) if res[1] == "um" else 1e6 / float(res[2]) # -> nm
nets = {str(k): v for k, v in board.GetNetsByName().items()}
existing_vias = {(round(TO(t.GetPosition().x), 3), round(TO(t.GetPosition().y), 3))
for t in board.GetTracks() if isinstance(t, pcbnew.PCB_VIA)}
n_seg = n_via = 0
per_net = {}
for net in sexp.find1(routes, "network_out")[1:]:
if not (isinstance(net, list) and net[0] == "net"):
continue
name = net[1]
ni = nets.get(name)
if ni is None or short(name) in HAND_NETS:
continue
for item in net[2:]:
if not isinstance(item, list):
continue
if item[0] == "wire":
typ = sexp.find1(item, "type")
if typ and typ[1] in ("protect", "fix"):
continue
path = sexp.find1(item, "path")
layer = F if path[1] == "F.Cu" else B
w = float(path[2]) * scale
c = [float(v) for v in path[3:] if not isinstance(v, list)]
pts = [(c[i] * scale, -c[i + 1] * scale) for i in range(0, len(c) - 1, 2)]
for a, b_ in zip(pts, pts[1:]):
t = pcbnew.PCB_TRACK(board)
t.SetStart(pcbnew.VECTOR2I(int(a[0]), int(a[1])))
t.SetEnd(pcbnew.VECTOR2I(int(b_[0]), int(b_[1])))
t.SetWidth(int(w))
t.SetLayer(layer)
t.SetNet(ni)
board.Add(t)
n_seg += 1
per_net[name] = per_net.get(name, 0) + 1
elif item[0] == "via":
x, y = float(item[2]) * scale, -float(item[3]) * scale
if (round(x / 1e6, 3), round(y / 1e6, 3)) in existing_vias:
continue
v = pcbnew.PCB_VIA(board)
v.SetPosition(pcbnew.VECTOR2I(int(x), int(y)))
v.SetWidth(MM(VIA_D))
v.SetDrill(MM(VIA_DRILL))
v.SetNet(ni)
board.Add(v)
n_via += 1
print(f"imported {n_seg} track segments and {n_via} vias on {len(per_net)} nets")
return n_seg, n_via
# ---- stage R3: copper planes -----------------------------------------------------------------
SPLIT_Y = 53.8 # front: +5V above (LED area), GND below in the USB / ESP32 strip
SPLIT_X = 112.0 # front: +5V also fills the bottom strip right of the eFuse
def make_zone(board, netname, layer, pts, priority, name):
z = pcbnew.ZONE(board)
z.SetLayer(layer)
z.SetNet(board.FindNet(netname))
z.SetZoneName(name)
z.SetAssignedPriority(priority)
z.SetLocalClearance(MM(0.25))
z.SetMinThickness(MM(0.25))
z.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL)
z.SetThermalReliefGap(MM(0.3))
z.SetThermalReliefSpokeWidth(MM(0.5))
z.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS) # no floating copper
o = z.Outline()
o.NewOutline()
for (x, y) in pts:
p = mb.pt(x, y)
o.Append(p.x, p.y)
board.Add(z)
return z
def stage_zones(board):
old = [z for z in board.Zones() if not z.GetIsRuleArea()]
if old: # KiCad's Python objects go stale after deletions: delete, save, reload
for z in old:
board.Remove(z)
pcbnew.SaveBoard(mb.PCB, board)
board = pcbnew.LoadBoard(mb.PCB)
W, H = mb.W, mb.H
make_zone(board, "GND", B, [(0, 0), (W, 0), (W, H), (0, H)], 0, "GND plane (back)")
make_zone(board, "+5V", F, [(0, 0), (W, 0), (W, H), (SPLIT_X, H), (SPLIT_X, SPLIT_Y), (0, SPLIT_Y)], 1,
"+5V fill (front)")
make_zone(board, "GND", F, [(0, SPLIT_Y), (SPLIT_X, SPLIT_Y), (SPLIT_X, H), (0, H)], 2, "GND fill (front)")
# Thermal reliefs everywhere (even heating of small 0402/0603 parts during reflow); solid connection only for
# the ESP32 module's ground pads and the eFuse / regulator exposed pads, which need full copper for heat.
for f in board.GetFootprints():
for p in f.Pads():
if short(p.GetNetname()) == "GND" and (f.GetReference() == "U4" or
(f.GetReference(), p.GetNumber()) in (("U2", "9"), ("U3", "7"))):
p.SetZoneConnection(pcbnew.ZONE_CONNECTION_FULL)
filler = pcbnew.ZONE_FILLER(board)
filler.Fill(board.Zones())
for z in board.Zones():
if not z.GetIsRuleArea():
print(f"{z.GetZoneName()}: filled area {z.GetFilledArea() / 1e12:.0f} mm^2")
return board
def stage_finish(board):
"""Stage R2b: Freerouting joined the ILM test pad (TP8) with a back-layer jump; a front-only path exists,
so the jump is replaced (maze search on the front layer only)."""
import route_fix as rf
from shapely.ops import nearest_points, unary_union
ts = [t for t in board.GetTracks() if not t.IsLocked() and short(t.GetNetname()) == "ILM"]
for t in ts:
board.Remove(t)
R = Router(board)
full = str(R.net("ILM").GetNetname())
isl = rf.islands(R.cu, board, full)
if len(isl) < 2:
return
ga = unary_union([g for g in isl[0].values() if g is not None])
gb = unary_union([g for g in isl[1].values() if g is not None])
pa, pb = nearest_points(ga, gb)
z = rf.Maze(R.cu, full, 0.25, (min(pa.x, pb.x) - 4, min(pa.y, pb.y) - 4, max(pa.x, pb.x) + 4, max(pa.y, pb.y) + 4))
path = z.search({k: z.cells_in(isl[0][k]) for k in "FB"}, {k: z.cells_in(isl[1][k]) for k in "FB"},
allow_back=False)
if not path:
raise SystemExit("no front-only ILM path")
R.track(rf.simplify([z.xy(i, j) for (_, i, j) in path]), 0.25, full, label="ILM to TP8 (front)")
print("ILM test-pad link re-routed on the front layer; problems:", R.problems)
usb_stitching(R)
def usb_stitching(R):
"""Ground stitching vias right beside every place where the USB pair changes layer, so the return current
can follow the signal between the back plane and the front ground fill (closest free spots, 2 per place)."""
usb = [t for t in R.b.GetTracks() if isinstance(t, pcbnew.PCB_VIA) and short(t.GetNetname()) in ("USB_DP", "USB_DN")]
pts = [xy(v.GetPosition()) for v in usb]
groups = []
for p in pts:
for g in groups:
if min(math.dist(p, q) for q in g) < 1.6:
g.append(p)
break
else:
groups.append([p])
added = []
for g in groups:
cx = sum(p[0] for p in g) / len(g)
cy = sum(p[1] for p in g) / len(g)
cands = []
for r in (1.0, 1.2, 1.4, 1.6, 1.8, 2.0):
for k in range(24):
a = 2 * math.pi * k / 24
cands.append((round(cx + r * math.cos(a), 2), round(cy + r * math.sin(a), 2)))
cands.sort(key=lambda c: min(math.dist(c, p) for p in g))
chosen = []
for c in cands:
if len(chosen) == 2:
break
if chosen and math.dist(c, chosen[0]) < 1.5: # one on each side of the pair where possible
continue
if not R.via_ok(c[0], c[1], "GND"):
R.via(c[0], c[1], "GND", check=False)
chosen.append(c)
added.append((tuple(round(v, 2) for v in (cx, cy)), chosen))
# every USB via individually: if no ground via is within 1.0 mm, add the closest free one
for p in pts:
gnd = [xy(t.GetPosition()) for t in R.b.GetTracks() if isinstance(t, pcbnew.PCB_VIA) and short(t.GetNetname()) == "GND"]
if min(math.dist(p, q) for q in gnd) <= 1.0:
continue
cands = sorted(((round(p[0] + r * math.cos(2 * math.pi * k / 36), 2), round(p[1] + r * math.sin(2 * math.pi * k / 36), 2))
for r in (0.9, 1.0, 1.1, 1.2, 1.35, 1.5) for k in range(36)), key=lambda c: math.dist(c, p))
for c in cands:
if not R.via_ok(c[0], c[1], "GND"):
R.via(c[0], c[1], "GND", check=False)
added.append((tuple(round(v, 2) for v in p), [c]))
break
for (c, ch) in added:
print(f"USB layer change near {c}: stitching vias at {ch} "
f"(nearest {min(math.dist(q, c) for q in ch):.2f} mm)" if ch else f"USB layer change near {c}: NO free spot")
def main():
stage = sys.argv[1] if len(sys.argv) > 1 else "pre"
board = pcbnew.LoadBoard(mb.PCB)
if stage == "pre":
R = stage_pre(board)
out = sys.argv[2] if len(sys.argv) > 2 else mb.PCB
pcbnew.SaveBoard(out, board)
print("saved", out)
elif stage == "auto":
work = os.environ.get("ROUTE_WORK", "/tmp")
ses = stage_auto(board, work) # works on an in-memory copy
print("SES:", ses, os.path.exists(ses))
elif stage == "finish":
stage_finish(board)
pcbnew.SaveBoard(mb.PCB, board)
print("saved", mb.PCB)
elif stage == "zones":
board = stage_zones(board)
pcbnew.SaveBoard(mb.PCB, board)
print("saved", mb.PCB)
elif stage == "import":
ses = sys.argv[2]
import_ses(board, ses)
pcbnew.SaveBoard(mb.PCB, board)
print("saved", mb.PCB)
else:
raise SystemExit("unknown stage " + stage)
if __name__ == "__main__":
main()