app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
master
John Lauer
ESC routing round: planes fixture (GND In1, +3V3 In2), Fable's grid router, offline DRC gate, live replay
f564403
25d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
#!/usr/bin/env python3
"""Apply a routing plan OFFLINE into a copy of the board and gate it with the shared KiCad DRC.
python3 demo/routing/esc/write_copper.py BOARD-planes.kicad_pcb PLAN.json [--out FILE]
[--no-fill-planes] [--drc] [--baseline BOARD.kicad_pcb]
[--report drc-report.json]
The plan is the list of `kicad_route_net` calls the router wrote (see ai_router.py): each entry
is {"net", "width", "points": [...]} or {"net", "width", "paths": [[...], ...]} plus optional
"viaSize" / "viaDrill". Waypoints are "REF.PAD" (the pad centre), [x, y], or {"x", "y",
"layer": "B.Cu"} / {"pad": "REF.PAD", "layer": ...}; a layer marker means a through via AT that
point and every following segment on the new layer, exactly as rust/crates/kicad-core/src/pcb.rs
`plan` reads it. Segments and vias are written as KiCad 10 s-expressions with the board's net
reference, a fresh uuid each, and spliced in before the closing paren.
Plane zones (GND on In1.Cu, +3V3 on In2.Cu from add_planes.py) have no fill in the file and the
shared `service-kicad pcb drc` does not refill zones, so by default this script also writes a
raster fill for every zone: overlapping 0.1 mm strips covering the outline (0.5 mm edge clearance)
minus every other-net through pad, via and hole expanded by the 0.2 mm clearance and the grid
slop. KiCad's own fill on the live board replaces it (thermal reliefs and all); here it lets the
DRC count each plane-net via as connected. --no-fill-planes leaves the zones unfilled.
--drc runs `service-kicad pcb drc` on the written file and prints errors, warnings, unconnected
items and the violation types. With --baseline, errors already present on the unrouted board
(same type, description and item positions) are listed separately as inherited, the way the
bridge's route_net compares before/after; the gate is zero NEW errors and zero unconnected.
"""
import argparse
import json
import math
import os
import subprocess
import sys
import tempfile
import uuid
from collections import Counter
from pathlib import Path
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from escboard import Board, Grid, fmt # noqa: E402
CLEARANCE = 0.2
EDGE_CLEARANCE = 0.5
SLOP = 0.1
# ----------------------------------------------------------------------------- plan -> copper
def _point(spec, board):
"""(x, y, pad, explicit_layer) for one waypoint, matching pcb.rs resolve_point."""
layer = None
pad = None
if isinstance(spec, str):
pad = board.pad_by_key(spec)
if pad is None:
raise ValueError("unknown pad %r" % spec)
return pad.x, pad.y, pad, None
if isinstance(spec, dict):
layer = spec.get("layer")
if spec.get("pad"):
pad = board.pad_by_key(str(spec["pad"]))
if pad is None:
raise ValueError("unknown pad %r" % spec["pad"])
return pad.x, pad.y, pad, layer
return float(spec["x"]), float(spec["y"]), None, layer
if isinstance(spec, (list, tuple)) and len(spec) == 2:
return float(spec[0]), float(spec[1]), None, None
raise ValueError("bad waypoint %r" % (spec,))
def plan_to_copper(board, entry):
"""One plan entry -> (segments, vias): segments (x0,y0,x1,y1,layer,width), vias (x,y,size,drill)."""
net = entry["net"]
if net not in board.nets:
raise ValueError("unknown net %r" % net)
width = float(entry.get("width", 0.25))
size = float(entry.get("viaSize", 0.6))
drill = float(entry.get("viaDrill", 0.3))
paths = entry.get("paths") or [entry["points"]]
segments, vias = [], []
for path in paths:
if not 2 <= len(path) <= 512:
raise ValueError("path needs 2..512 waypoints (%s)" % net)
resolved = [_point(p, board) for p in path]
for x, y, pad, _ in resolved:
if pad is not None and pad.net != net:
raise ValueError("pad %s is on %s, not %s" % (pad.key, pad.net, net))
layer = entry.get("layer") or resolved[0][3] or "F.Cu"
for i, (x, y, pad, explicit) in enumerate(resolved):
nxt = explicit or layer
if i > 0:
px, py = resolved[i - 1][0], resolved[i - 1][1]
if (px, py) != (x, y):
segments.append((px, py, x, y, layer, width))
if nxt != layer:
vias.append((x, y, size, drill))
layer = nxt
if pad is not None and layer not in pad.layers:
raise ValueError("pad %s has no copper on %s" % (pad.key, layer))
return segments, vias
def segment_sexpr(board, s, net):
x0, y0, x1, y1, layer, w = s
return ("\t(segment\n\t\t(start %s %s)\n\t\t(end %s %s)\n\t\t(width %s)\n\t\t(layer \"%s\")\n%s\t\t(uuid \"%s\")\n\t)"
% (fmt(x0), fmt(y0), fmt(x1), fmt(y1), fmt(w), layer, board.net_ref_sexpr(net), uuid.uuid4()))
def via_sexpr(board, v, net):
x, y, size, drill = v
return ("\t(via\n\t\t(at %s %s)\n\t\t(size %s)\n\t\t(drill %s)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n%s\t\t(uuid \"%s\")\n\t)"
% (fmt(x), fmt(y), fmt(size), fmt(drill), board.net_ref_sexpr(net), uuid.uuid4()))
def splice(text, blocks):
trimmed = text.rstrip()
idx = trimmed.rfind(")")
body = text[:idx].rstrip()
nl = "\r\n" if "\r\n" in text[:2000] else "\n"
return body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl
# ----------------------------------------------------------------------------- plane raster fill
def zone_fill_polygons(board, zone_net, zone_layer, all_vias_by_net, grid=None):
"""ONE fractured polygon per connected region of the plane (KiCad's own fill format: the outer
contour with every hole spliced in through a zero-width slit). KiCad does not connect
overlapping sub-polygons of one zone to each other, so a fill made of strips would be a set
of islands; a single polygon is one island the way a real fill is.
all_vias_by_net: {net: [(x, y, size, drill), ...]} for every via the plan adds."""
grid = grid or Grid(board.bbox)
free = grid.outline_inside(board.outline, EDGE_CLEARANCE + SLOP)
block = np.zeros((grid.H, grid.W), dtype=bool)
by_clearance = {} # pads with a local clearance override are dilated by their own value
for p in board.pads:
if not p.thru or p.net == zone_net:
continue
target = block if p.clearance <= CLEARANCE else by_clearance.setdefault(p.clearance, np.zeros((grid.H, grid.W), dtype=bool))
for poly in p.polys:
grid.poly_mask(poly, target)
if p.drill:
jj, ii = grid.disk_cells((p.x, p.y), p.drill / 2.0)
target[jj, ii] = True
for net, vias in all_vias_by_net.items():
if net == zone_net:
continue
for x, y, size, drill in vias:
jj, ii = grid.disk_cells((x, y), size / 2.0)
block[jj, ii] = True
block = grid.dilate(block, (CLEARANCE + SLOP) / grid.res)
for clr, mask in by_clearance.items():
block |= grid.dilate(mask, (clr + SLOP) / grid.res)
free &= ~block
loops = _trace_boundaries(free)
outers = [lp for lp in loops if _signed_area(lp) > 0]
holes = [lp for lp in loops if _signed_area(lp) < 0]
polys = []
for outer in outers:
ob = _bbox(outer)
mine = []
for h in holes:
hb = _bbox(h)
if hb[0] < ob[0] or hb[1] < ob[1] or hb[2] > ob[2] or hb[3] > ob[3]:
continue
if _point_in_poly(h[0][0] + 1e-6, h[0][1] + 1e-6, outer):
mine.append(h)
poly = _fracture(outer, mine)
polys.append([(grid.x0 + x * grid.res, grid.y0 + y * grid.res) for x, y in poly])
return polys
def _trace_boundaries(free):
"""Boundary loops of a binary raster as lattice polygons (cell-corner coordinates, in cells).
Region on the right of the direction of travel; outer loops and holes get opposite signed areas."""
H, W = free.shape
padded = np.zeros((H + 2, W + 2), dtype=bool)
padded[1:-1, 1:-1] = free
f = padded
# directed edges around free cells whose neighbour across that side is not free
edges = []
jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[0:-2, 1:-1]) # top side: TL -> TR
edges += [((i - 0.5, j - 0.5), (i + 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]
jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 2:]) # right side: TR -> BR
edges += [((i + 0.5, j - 0.5), (i + 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]
jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[2:, 1:-1]) # bottom side: BR -> BL
edges += [((i + 0.5, j + 0.5), (i - 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]
jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 0:-2]) # left side: BL -> TL
edges += [((i - 0.5, j + 0.5), (i - 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]
out = {}
for a, b in edges:
out.setdefault(a, []).append(b)
loops = []
while out:
a = next(iter(out))
b = out[a].pop()
if not out[a]:
del out[a]
loop = [a, b]
prev = (b[0] - a[0], b[1] - a[1])
while True:
cur = loop[-1]
cands = out.get(cur)
if not cands:
break
if len(cands) == 1:
nxt = cands.pop()
else:
# a pinch point: prefer the right turn (toward the region), then straight, then left
right = (-prev[1], prev[0])
pick = None
for want in (right, prev, (prev[1], -prev[0])):
for c in cands:
if (c[0] - cur[0], c[1] - cur[1]) == want:
pick = c
break
if pick is not None:
break
nxt = pick if pick is not None else cands[-1]
cands.remove(nxt)
if not cands:
del out[cur]
prev = (nxt[0] - cur[0], nxt[1] - cur[1])
if nxt == loop[0]:
break
loop.append(nxt)
# drop collinear vertices
simp = []
n = len(loop)
for k in range(n):
p0, p1, p2 = loop[k - 1], loop[k], loop[(k + 1) % n]
if (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) != 0:
simp.append(p1)
if len(simp) >= 4:
loops.append(simp)
return loops
def _signed_area(poly):
a = 0.0
for k in range(len(poly)):
x0, y0 = poly[k]
x1, y1 = poly[(k + 1) % len(poly)]
a += x0 * y1 - x1 * y0
return a / 2.0
def _bbox(poly):
xs = [p[0] for p in poly]
ys = [p[1] for p in poly]
return min(xs), min(ys), max(xs), max(ys)
def _point_in_poly(x, y, poly):
inside = False
n = len(poly)
for k in range(n):
ax, ay = poly[k]
bx, by = poly[(k + 1) % n]
if (ay > y) != (by > y):
if x < ax + (y - ay) * (bx - ax) / (by - ay):
inside = not inside
return inside
def _fracture(outer, holes):
"""Splice every hole into the outer contour through a horizontal zero-width slit to the left,
holes in order of their left edge so a slit only ever meets contour that is already part of
the polygon. The slit leaves the hole from the middle of its leftmost vertical edge, so it never
hits a lattice vertex."""
poly = list(outer)
def leftmost_edge(h):
best = None
n = len(h)
for k in range(n):
a, b = h[k], h[(k + 1) % n]
if a[0] == b[0] and (best is None or a[0] < best[0]):
best = (a[0], k)
return best
order = sorted(range(len(holes)), key=lambda k: leftmost_edge(holes[k])[0])
for hk in order:
h = holes[hk]
x_h, k = leftmost_edge(h)
a, b = h[k], h[(k + 1) % len(h)]
ym = (a[1] + b[1]) / 2.0
if abs(b[1] - a[1]) >= 2:
ym = a[1] + (0.5 if b[1] > a[1] else -0.5) # a whole cell from the vertex: never a lattice vertex
start = (x_h, ym)
# hole contour starting at the slit point
hole = [start] + h[k + 1:] + h[:k + 1] + [start]
# nearest vertical edge of the polygon to the left of the slit point at height ym
hit = None
for e in range(len(poly)):
p0, p1 = poly[e], poly[(e + 1) % len(poly)]
if p0[0] == p1[0] and p0[0] < x_h and min(p0[1], p1[1]) < ym < max(p0[1], p1[1]):
if hit is None or p0[0] > hit[0]:
hit = (p0[0], e)
if hit is None:
continue # nothing to the left inside this outer: leave the hole out (cannot happen for a hole)
xe, e = hit
joint = (xe, ym)
poly = poly[:e + 1] + [joint] + hole + [joint] + poly[e + 1:]
return poly
def zones_of(board):
"""[(node, net, layer)] for every top-level zone."""
out = []
for z in board.root.find_all("zone"):
n = z.find("net")
net = n.value() if n is not None else ""
if board.net_format == "number" and n is not None:
try:
net = board.net_table.get(int(n.atom(1)), net)
except (TypeError, ValueError):
pass
nn = z.find("net_name")
if nn is not None:
net = nn.value()
layer = z.find("layer").value() if z.find("layer") is not None else None
out.append((z, net, layer))
return out
def fill_zones(board, text, vias_by_net):
"""Return the board text with a filled_polygon list appended inside every zone."""
grid = Grid(board.bbox)
edits = []
stats = {}
for node, net, layer in zones_of(board):
if not net or not layer:
continue
polys = zone_fill_polygons(board, net, layer, vias_by_net, grid)
blocks = []
for poly in polys:
pts = " ".join("(xy %s %s)" % (fmt(x), fmt(y)) for x, y in poly)
blocks.append("\t\t(filled_polygon\n\t\t\t(layer \"%s\")\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % (layer, pts))
# insert before the zone's closing paren
edits.append((node.end - 1, node.end - 1, "".join(blocks) + "\t"))
# a raster fill has no thermal spokes: connect pads solidly in this gate copy so the DRC
# does not report starved thermals on the through pads inside the plane
cp = node.find("connect_pads")
if cp is not None and cp.atom(1) not in ("yes", "no", "thru_hole_only"):
edits.append((cp.start, cp.start + len("(connect_pads"), "(connect_pads yes"))
stats[net] = len(polys)
edits.sort()
out = []
pos = 0
for at, end, ins in edits:
out.append(text[pos:at])
out.append(ins)
pos = end
out.append(text[pos:])
return "".join(out), stats
# ----------------------------------------------------------------------------- DRC
def run_drc(path):
with tempfile.TemporaryDirectory() as tmp:
report = Path(tmp) / "drc.json"
proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],
capture_output=True, text=True, timeout=900)
if proc.returncode or not report.is_file():
raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-600:], proc.stderr[-600:]))
return json.loads(report.read_text(encoding="utf-8"))
def fingerprint(v):
return json.dumps([v.get("type"), v.get("severity"), v.get("description"),
sorted(json.dumps(i.get("pos"), sort_keys=True) for i in v.get("items", []))], sort_keys=True)
def summarize(data, baseline=None):
viol = data.get("violations", [])
errors = [v for v in viol if v.get("severity") == "error"]
warnings = [v for v in viol if v.get("severity") == "warning"]
inherited = []
new = errors
if baseline is not None:
existing = Counter(fingerprint(v) for v in baseline.get("violations", []) if v.get("severity") == "error")
new, inherited = [], []
for v in errors:
k = fingerprint(v)
if existing.get(k, 0) > 0:
existing[k] -= 1
inherited.append(v)
else:
new.append(v)
unconnected = data.get("unconnected_items", [])
# isolated_copper warnings on a raster-filled plane are the strips themselves (each filled
# polygon that touches no pad is an "island" to KiCad); the live fill has none of them
fill_artifacts = [v for v in warnings if v.get("type") == "isolated_copper"]
warnings = [v for v in warnings if v.get("type") != "isolated_copper"]
return {
"errors": len(errors), "newErrors": len(new), "inheritedErrors": len(inherited),
"warnings": len(warnings), "unconnected": len(unconnected),
"rasterFillIslandWarnings": len(fill_artifacts),
"errorTypes": dict(Counter(v.get("type") for v in errors)),
"newErrorTypes": dict(Counter(v.get("type") for v in new)),
"warningTypes": dict(Counter(v.get("type") for v in warnings)),
"newErrorList": [{"type": v.get("type"), "description": v.get("description"),
"items": [i.get("description") for i in v.get("items", [])],
"pos": [i.get("pos") for i in v.get("items", [])]} for v in new],
"unconnectedList": [[i.get("description") for i in u.get("items", [])] for u in unconnected],
"kicadVersion": data.get("kicad_version"),
}
# ----------------------------------------------------------------------------- main
def apply(board, plan, fill_planes=True):
"""Return (text, stats) for the board text with the plan's copper (and optional fills)."""
entries = plan["nets"] if isinstance(plan, dict) else plan
blocks = []
vias_by_net = {}
n_seg = n_via = 0
length = 0.0
widths = Counter()
for entry in entries:
segs, vias = plan_to_copper(board, entry)
for s in segs:
blocks.append(segment_sexpr(board, s, entry["net"]))
length += math.hypot(s[2] - s[0], s[3] - s[1])
widths[s[5]] += 1
for v in vias:
blocks.append(via_sexpr(board, v, entry["net"]))
vias_by_net.setdefault(entry["net"], []).append(v)
n_seg += len(segs)
n_via += len(vias)
text = splice(board.text, blocks)
stats = {"entries": len(entries), "nets": len({e["net"] for e in entries}), "segments": n_seg, "vias": n_via,
"copperLengthMm": round(length, 2), "segmentsByWidth": {str(k): v for k, v in sorted(widths.items())}}
if fill_planes:
text, fills = fill_zones(board, text, vias_by_net)
stats["zoneFillPolygons"] = fills
return text, stats
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("board", help="the board WITH planes (add_planes.py output)")
ap.add_argument("plan", help="ai_router.py plan JSON")
ap.add_argument("--out", help="output .kicad_pcb (default: <plan>-routed.kicad_pcb beside the plan)")
ap.add_argument("--no-fill-planes", action="store_true")
ap.add_argument("--drc", action="store_true")
ap.add_argument("--baseline", help="unrouted board whose DRC errors are inherited (default: the input board)")
ap.add_argument("--report", help="write the DRC summary JSON here")
a = ap.parse_args(argv)
board = Board(a.board)
plan = json.load(open(a.plan, encoding="utf-8"))
text, stats = apply(board, plan, fill_planes=not a.no_fill_planes)
out = Path(a.out) if a.out else Path(a.plan).with_name(Path(a.plan).stem + "-routed.kicad_pcb")
out.write_text(text, encoding="utf-8", newline="")
Board(str(out))
print("%s: %s" % (out, json.dumps(stats)))
if a.drc:
base = run_drc(a.baseline or a.board)
data = run_drc(out)
s = summarize(data, base)
s["copper"] = stats
s["file"] = str(out)
print("drc: %d errors (%d new, %d inherited), %d warnings (+%d raster-fill island warnings), %d unconnected; new error types %s; warning types %s; KiCad %s" % (
s["errors"], s["newErrors"], s["inheritedErrors"], s["warnings"], s["rasterFillIslandWarnings"], s["unconnected"],
json.dumps(s["newErrorTypes"], sort_keys=True), json.dumps(s["warningTypes"], sort_keys=True), s["kicadVersion"]))
for v in s["newErrorList"][:40]:
print(" NEW %s: %s %s" % (v["type"], v["description"], v["items"]))
for u in s["unconnectedList"][:40]:
print(" UNCONNECTED %s" % (u,))
if a.report:
Path(a.report).write_text(json.dumps(s, indent=1) + "\n", encoding="utf-8")
return 0 if (s["newErrors"] == 0 and s["unconnected"] == 0) else 2
return 0
if __name__ == "__main__":
sys.exit(main())