app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
ESC routing to 100%: the place-route loop (smaller signal vias, 13 parts moved, 114 commits, 0 unconnected), kicad-plac…
df61ffd
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
#!/usr/bin/env python3
"""Move footprints in a .kicad_pcb file offline (the placement-adjustment step of the place-route loop).
python3 tools/move_footprints.py BOARD.kicad_pcb --out MOVED.kicad_pcb --move REF=x,y[,rot] [--move ...]
[--json moves.json] [--check]
A footprint's pads, courtyard and text ride along with its `(at x y rot)`, so moving the anchor moves
the part. --json takes {"REF": {"x":..,"y":..,"rotation":..}, ...} (the shape kicad_move_footprint takes).
--check reports courtyard-box overlaps and parts outside the Edge.Cuts bbox after the move, from the
file's own geometry, so the plan can be gated before it goes anywhere near the live board (the bridge's
kicad_move_footprint does the exact check when the plan is landed).
"""
import argparse, json, math, re, sys
def footprint_blocks(text):
"""Yield (start, end, ref) for every top-level (footprint ...) block."""
for m in re.finditer(r'\n\t\(footprint ', text):
s = m.start() + 1
depth = 0; i = s
while True:
c = text[i]
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0:
break
i += 1
block = text[s:i + 1]
r = re.search(r'\(property "Reference" "([^"]+)"', block)
yield s, i + 1, (r.group(1) if r else None)
def move_block(block, x, y, rot):
m = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', block)
if not m:
raise SystemExit("no footprint (at ...) found")
ox, oy, orot = float(m.group(1)), float(m.group(2)), float(m.group(3) or 0)
nrot = orot if rot is None else rot
drot = nrot - orot
new = "\n\t\t(at %s %s%s)" % (fmt(x), fmt(y), "" if nrot == 0 else " " + fmt(nrot))
block = block[:m.start()] + new + block[m.end():]
if abs(drot) > 1e-9:
# pads and text carry the footprint rotation in their own (at ...): rotate those too
def rot_at(mm):
a = float(mm.group(4) or 0) + drot
a = ((a + 180) % 360) - 180
return "(at %s %s%s)" % (mm.group(2), mm.group(3), "" if abs(a) < 1e-9 else " " + fmt(a))
# only the children (pads, properties, graphics: three tabs or deeper), never the footprint's own (at ...)
block = re.sub(r'(\n\t\t\t+)\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', lambda mm: mm.group(1) + rot_at(mm), block)
return block, (ox, oy, orot)
def fmt(v):
s = "%.6f" % v
s = s.rstrip("0").rstrip(".")
return s if s not in ("", "-0") else "0"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("board"); ap.add_argument("--out", required=True)
ap.add_argument("--move", action="append", default=[], help="REF=x,y[,rot]")
ap.add_argument("--json", help="moves as {REF: {x, y, rotation}}")
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
moves = {}
if a.json:
for ref, m in json.load(open(a.json)).items():
moves[ref] = (float(m["x"]), float(m["y"]), None if m.get("rotation") is None else float(m["rotation"]))
for mv in a.move:
ref, rest = mv.split("=", 1)
parts = [float(v) for v in rest.split(",")]
moves[ref] = (parts[0], parts[1], parts[2] if len(parts) > 2 else None)
text = open(a.board, encoding="utf-8").read()
out = []; last = 0; done = {}
for s, e, ref in footprint_blocks(text):
if ref in moves:
block, old = move_block(text[s:e], *moves[ref])
out.append(text[last:s]); out.append(block); last = e
done[ref] = old
out.append(text[last:])
text = "".join(out)
missing = sorted(set(moves) - set(done))
if missing:
raise SystemExit("not found: %s" % missing)
open(a.out, "w", encoding="utf-8").write(text)
for ref, (ox, oy, orot) in done.items():
x, y, rot = moves[ref]
print("moved %-6s (%.2f, %.2f, %g) -> (%.2f, %.2f, %s)" % (ref, ox, oy, orot, x, y, "same" if rot is None else "%g" % rot))
if a.check:
sys.path.insert(0, __import__("os").path.join(__import__("os").path.dirname(__import__("os").path.abspath(__file__)), "..", "demo", "routing", "esc"))
from escboard import Board
b = Board(a.out)
boxes = {}
for p in b.pads:
xs = [q[0] for poly in p.polys for q in poly]; ys = [q[1] for poly in p.polys for q in poly]
bx = boxes.setdefault(p.ref, [1e9, 1e9, -1e9, -1e9])
bx[0] = min(bx[0], min(xs)); bx[1] = min(bx[1], min(ys)); bx[2] = max(bx[2], max(xs)); bx[3] = max(bx[3], max(ys))
gap = 0.25 # courtyard-ish gap between pad boxes
refs = sorted(boxes)
bad = 0
for i, r1 in enumerate(refs):
for r2 in refs[i + 1:]:
A, B = boxes[r1], boxes[r2]
if A[0] - gap < B[2] and B[0] - gap < A[2] and A[1] - gap < B[3] and B[1] - gap < A[3]:
if r1 in moves or r2 in moves:
print("OVERLAP (pad boxes within %.2f mm): %s %s" % (gap, r1, r2)); bad += 1
(x0, y0, x1, y1) = b.bbox
for ref in moves:
A = boxes.get(ref)
if A and (A[0] < x0 + 0.5 or A[1] < y0 + 0.5 or A[2] > x1 - 0.5 or A[3] > y1 - 0.5):
print("OUTSIDE: %s" % ref); bad += 1
print("check: %d problems" % bad)
return 2 if bad else 0
if __name__ == "__main__":
sys.exit(main())