Files
Plasma-DMD/hardware/kicad/route_pcb.py
T
mangavalkandClaude Opus 5.5 28c666506e Add routed PSU carrier board layout (rev 0.1)
- Placement script (layout_pcb.py): LV left, HV right, panel connector on the
  boundary, terminal blocks on the edges, heatsink space for the TO-220s
- Freerouting-based routing script (route_pcb.py) that routes with oversized
  tracks/vias and shrinks them, giving 3 mm HV-to-LV and 1.3 mm HV-to-HV
- Custom DRC rules (plasma_dmd_psu.kicad_dru) with documented exceptions
- Wide TO-92 footprints; schematic generator now keeps symbol UUIDs
- DRC: 0 violations, 0 unconnected pads, schematic parity OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:58:35 +02:00

211 lines
8.2 KiB
Python

"""Autoroute the PSU carrier board with Freerouting.
Run with KiCad's bundled Python (KiCad must not have the board open):
"C:/Users/patri/AppData/Local/Programs/KiCad/10.0/bin/python.exe" hardware/kicad/route_pcb.py
Exports a Specctra DSN, adds class-to-class clearance (HV <-> everything else 3 mm, matching
plasma_dmd_psu.kicad_dru, which Freerouting does not read), routes, and imports the session.
Existing tracks are removed first, so the result always comes from the current placement.
"""
import re
import subprocess
import sys
from pathlib import Path
import pcbnew
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
BOARD = HERE / "plasma_dmd_psu.kicad_pcb"
WORK = HERE / "route"
JAVA = Path("C:/Program Files/Eclipse Adoptium/jre-25.0.4.101-hotspot/bin/java.exe")
JAR = ROOT / "tools/freerouting/freerouting-2.5.0.jar"
PASSES = int(sys.argv[1]) if len(sys.argv) > 1 else 100
# Route with oversized tracks, then shrink them to the real width: every track then keeps
# (routing clearance + half the width difference) from its neighbours. This gets 3 mm HV <-> LV
# and 1.3 mm HV <-> HV while tracks can still squeeze out between transistor pins.
# class: (routing width um, routing clearance um, final width mm)
ROUTE = {"kicad_default": (1800, 250, 0.4), "Power": (2400, 300, 1.0), "HV": (2100, 650, 0.6)}
VIA_ROUTE_UM, VIA_FINAL_MM, VIA_DRILL_MM = 2300, 1.0, 0.5 # same trick for vias
HV_TO_LV_UM = 2450 # + (2.1 - 0.6) / 2 after shrinking = 3.2 mm
def add_gnd_plane(board):
"""Bottom-layer GND pour over the whole board (custom rules keep it 3 mm from HV)."""
for z in list(board.Zones()):
board.Delete(z)
bb = board.GetBoardEdgesBoundingBox()
inset = pcbnew.FromMM(0.5)
x0, y0 = bb.GetX() + inset, bb.GetY() + inset
x1, y1 = bb.GetRight() - inset, bb.GetBottom() - inset
z = pcbnew.ZONE(board)
z.SetLayer(pcbnew.B_Cu)
z.SetNet(board.FindNet("/GND"))
z.SetLocalClearance(pcbnew.FromMM(0.5))
z.SetMinThickness(pcbnew.FromMM(0.5))
z.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL)
z.SetThermalReliefGap(pcbnew.FromMM(0.5))
z.SetThermalReliefSpokeWidth(pcbnew.FromMM(0.8))
ol = z.Outline()
ol.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
ol.Append(x, y)
board.Add(z)
def add_connector_stubs(board, ref="J2", length=5.0):
"""Fixed escape stubs on the panel connector: LV pins exit left, HV pins exit right.
Its 3.96 mm pitch is tighter than the router's HV <-> LV clearance, so the router could not
leave those pins by itself. GND pins connect to the bottom plane and need no stub.
"""
fp = board.FindFootprintByReference(ref)
# final widths: a router track must end on the stub copper itself, which still holds after shrinking
widths = {"HV": ROUTE["HV"][2], "Power": ROUTE["Power"][2]}
for pad in fp.Pads():
net = pad.GetNet()
name = net.GetNetname() if net else ""
if not name or name == "/GND" or name.startswith("unconnected"):
continue
cls = pad.GetNetClassName()
sign = 1 if cls == "HV" else -1
p = pad.GetPosition()
t = pcbnew.PCB_TRACK(board)
t.SetStart(p)
t.SetEnd(pcbnew.VECTOR2I(p.x + sign * pcbnew.FromMM(length), p.y))
t.SetLayer(pcbnew.F_Cu)
t.SetWidth(pcbnew.FromMM(widths.get(cls, ROUTE["kicad_default"][2])))
t.SetNet(net)
t.SetLocked(True)
board.Add(t)
def unconnected(board):
board.BuildConnectivity()
return board.GetConnectivity().GetUnconnectedCount(False)
def remove_dangling_stubs(board):
"""Drop duplicate escape stubs (the SES brings them back), then delete each stub whose
removal does not leave anything unconnected (the router may join a stub anywhere along it)."""
seen = set()
for t in [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T and t.IsLocked()]:
key = (t.GetNetCode(), t.GetLayer(), t.GetStart().x, t.GetStart().y, t.GetEnd().x, t.GetEnd().y)
if key in seen:
board.Delete(t)
seen.add(key)
base = unconnected(board)
for t in [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T and t.IsLocked()]:
s, e = t.GetStart(), t.GetEnd() # copy values: t is freed by Delete
keep = (s.x, s.y, e.x, e.y, t.GetLayer(), t.GetWidth(), t.GetNetCode())
board.Delete(t)
if unconnected(board) > base: # it was needed: put it back
n = pcbnew.PCB_TRACK(board)
n.SetStart(pcbnew.VECTOR2I(keep[0], keep[1])); n.SetEnd(pcbnew.VECTOR2I(keep[2], keep[3]))
n.SetLayer(keep[4]); n.SetWidth(keep[5])
board.Add(n)
n.SetNet(board.FindNet(keep[6])); n.SetLocked(True)
def patch_via_padstacks(text):
"""Enlarge the via pads in the DSN library (vias are set back to their real size after import)."""
out = []
in_via = False
for line in text.splitlines(keepends=True):
if line.lstrip().startswith('(padstack "Via'):
in_via = True
elif in_via and line.lstrip().startswith("(shape (circle"):
line = re.sub(r"(\(circle \S+ )[\d.]+", lambda m: m.group(1) + str(VIA_ROUTE_UM), line)
elif in_via and line.strip() == ")":
in_via = False
out.append(line)
return "".join(out)
def patch_class_rules(text):
"""Replace each class's width/clearance in the DSN with the routing values."""
out, pos = [], 0
for m in re.finditer(r"\(class (\S+) ", text):
name = m.group(1)
start = m.start()
rule = text.index("(rule", start)
end = text.index(")", text.index("(clearance", rule)) + 1
width, clear, _ = ROUTE[name]
block = text[rule:end]
block = re.sub(r"\(width [\d.]+\)", f"(width {width})", block)
block = re.sub(r"\(clearance [\d.]+\)", f"(clearance {clear})", block, count=1)
out.append(text[pos:rule] + block)
pos = end
out.append(text[pos:])
return "".join(out)
def shrink_tracks(board):
final = {("Default" if k == "kicad_default" else k): pcbnew.FromMM(v[2]) for k, v in ROUTE.items()}
for t in board.GetTracks():
if t.Type() == pcbnew.PCB_VIA_T:
t.SetWidth(pcbnew.FromMM(VIA_FINAL_MM))
t.SetDrill(pcbnew.FromMM(VIA_DRILL_MM))
elif t.Type() == pcbnew.PCB_TRACE_T:
name = t.GetNetClassName()
t.SetWidth(final.get(name, final["Default"]))
def main():
WORK.mkdir(exist_ok=True)
dsn, ses = WORK / "board.dsn", WORK / "board.ses"
board = pcbnew.LoadBoard(str(BOARD))
try:
tracks = list(board.GetTracks())
except TypeError:
tracks = []
for t in tracks:
board.Delete(t)
for z in list(board.Zones()):
board.Delete(z) # GND is routed as tracks; the pour is added afterwards
add_connector_stubs(board)
if not pcbnew.ExportSpecctraDSN(board, str(dsn)):
raise SystemExit("DSN export failed")
text = dsn.read_text(encoding="utf-8")
classes = re.findall(r"\(class (\S+) ", text)
text = patch_class_rules(text)
text = patch_via_padstacks(text)
extra = "".join(
f" (class_class (classes HV {c})\n (rule (clearance {HV_TO_LV_UM}))\n )\n"
for c in classes if c != "HV")
# insert after the last (class ...) block inside (network ...)
last = text.rfind(" (class ")
depth, i = 0, last
while True:
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
break
i += 1
text = text[:i + 1] + "\n" + extra + text[i + 1:]
dsn.write_text(text, encoding="utf-8")
ses.unlink(missing_ok=True)
subprocess.run([str(JAVA), "-jar", str(JAR), "-de", str(dsn), "-do", str(ses),
"-mp", str(PASSES), "-l", "en"], check=True, cwd=WORK) # logs stay in route/
if not ses.exists():
raise SystemExit("Freerouting produced no session file")
if not pcbnew.ImportSpecctraSES(board, str(ses)):
raise SystemExit("SES import failed")
shrink_tracks(board)
remove_dangling_stubs(board)
add_gnd_plane(board)
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
board.Save(str(BOARD))
print("routed and saved", BOARD.name)
if __name__ == "__main__":
main()