2026-10-24 02:58:35 +02:00
|
|
|
"""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)
|
2026-10-24 15:07:58 +02:00
|
|
|
ROUTE = {"kicad_default": (1800, 250, 0.4), "Power": (2400, 300, 1.0), "Power3A": (2900, 300, 1.5),
|
|
|
|
|
"HV": (2100, 650, 0.6)}
|
2026-10-24 02:58:35 +02:00
|
|
|
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
|
2026-10-24 15:07:58 +02:00
|
|
|
widths = {k: v[2] for k, v in ROUTE.items()}
|
2026-10-24 02:58:35 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-24 15:07:58 +02:00
|
|
|
def _closest_on_segment(p, a, b):
|
|
|
|
|
ax, ay, bx, by = a.x, a.y, b.x, b.y
|
|
|
|
|
dx, dy = bx - ax, by - ay
|
|
|
|
|
L2 = dx * dx + dy * dy
|
|
|
|
|
u = 0.0 if L2 == 0 else max(0.0, min(1.0, ((p.x - ax) * dx + (p.y - ay) * dy) / L2))
|
|
|
|
|
cx, cy = ax + u * dx, ay + u * dy
|
|
|
|
|
return int(round(cx)), int(round(cy)), ((p.x - cx) ** 2 + (p.y - cy) ** 2) ** 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def heal_t_junctions(board, reach_mm=1.6):
|
|
|
|
|
"""After shrinking, a track that joined another track mid-way (a T-junction) can end beside
|
|
|
|
|
the narrowed copper. Snap such dangling ends onto the centre line of the same-net track."""
|
|
|
|
|
traces = [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T]
|
|
|
|
|
vias = [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_VIA_T]
|
|
|
|
|
pads = [p for f in board.GetFootprints() for p in f.Pads()]
|
|
|
|
|
reach = pcbnew.FromMM(reach_mm)
|
|
|
|
|
fixed = 0
|
|
|
|
|
for t in traces:
|
|
|
|
|
for is_start in (True, False):
|
|
|
|
|
p = t.GetStart() if is_start else t.GetEnd()
|
|
|
|
|
net, layer = t.GetNetCode(), t.GetLayer()
|
|
|
|
|
if any(pd.GetNetCode() == net and pd.IsOnLayer(layer) and pd.HitTest(p) for pd in pads):
|
|
|
|
|
continue
|
|
|
|
|
if any(v.GetNetCode() == net and v.GetPosition() == p for v in vias):
|
|
|
|
|
continue
|
|
|
|
|
best, touching = None, False
|
|
|
|
|
for o in traces:
|
|
|
|
|
if o is t or o.GetNetCode() != net or o.GetLayer() != layer:
|
|
|
|
|
continue
|
|
|
|
|
if p in (o.GetStart(), o.GetEnd()):
|
|
|
|
|
touching = True
|
|
|
|
|
break
|
|
|
|
|
cx, cy, d = _closest_on_segment(p, o.GetStart(), o.GetEnd())
|
|
|
|
|
if d <= o.GetWidth() / 2:
|
|
|
|
|
touching = True
|
|
|
|
|
break
|
|
|
|
|
if d <= reach and (best is None or d < best[2]):
|
|
|
|
|
best = (cx, cy, d)
|
|
|
|
|
if touching or best is None:
|
|
|
|
|
continue
|
|
|
|
|
q = pcbnew.VECTOR2I(best[0], best[1])
|
|
|
|
|
if is_start:
|
|
|
|
|
t.SetStart(q)
|
|
|
|
|
else:
|
|
|
|
|
t.SetEnd(q)
|
|
|
|
|
fixed += 1
|
|
|
|
|
return fixed
|
|
|
|
|
|
|
|
|
|
|
2026-10-24 02:58:35 +02:00
|
|
|
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)
|
2026-10-24 15:07:58 +02:00
|
|
|
print("healed T-junctions:", heal_t_junctions(board))
|
2026-10-24 02:58:35 +02:00
|
|
|
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()
|