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 demo: Fable placement redone with the molecule interface fixed; Astra prompt updated
723663d
25d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
#!/usr/bin/env python3
"""Placement planner for the ESC G431 demo (Claude Fable 5.1's own placement).
Reads the KiCad 10 board, ignores the human placement, and produces a plan:
ref -> (x, y, rotation) inside the outline, no courtyard overlaps.
Method (what a layout engineer does, made explicit):
1. Macro placement of the eleven multi-pin parts by function, from the netlist:
the six MOSFETs form the 3-phase bridge on the right edge with the high side
above the low side per phase; the gate driver sits just left of the bridge;
the MCU left of the driver; the buck near the battery input at the top; the
current-sense amplifier at the low-side sources near the bottom; the debug
header on the far left edge.
2. Every two-pad part is attached to the multi-pin part it shares the most
nets with (decoupling caps to their IC, gate resistors to the driver/MOSFET,
bootstrap caps to the driver, sense resistors to the INA) and placed on an
occupancy grid in expanding rings around that anchor, rotated to match the
ring side, never overlapping a courtyard, never outside the outline.
3. Score: total MST ratsnest length over all nets, before and after.
"""
import json, math, re, sys, collections
from pathlib import Path
SRC = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("esc-g431.kicad_pcb")
OUT = Path(sys.argv[2]) if len(sys.argv) > 2 else SRC.with_name("esc-placement-plan.json")
s = SRC.read_text(errors="ignore")
# ---------- outline ----------
edge = []
for m in re.finditer(r'\((gr_line|gr_rect|gr_arc|gr_circle)\s(.*?)\n\t\)\n', s, re.S):
blk = m.group(0)
if 'Edge.Cuts' in blk:
edge += [(float(x), float(y)) for x, y in re.findall(r'\((?:start|end|center|mid) ([-\d.]+) ([-\d.]+)\)', blk)]
X0, X1 = min(p[0] for p in edge), max(p[0] for p in edge)
Y0, Y1 = min(p[1] for p in edge), max(p[1] for p in edge)
MARGIN = 1.0 # keep courtyards this far inside the edge
# ---------- footprints ----------
def rot(px, py, deg):
a = math.radians(deg); c, si = math.cos(a), math.sin(a)
return px * c - py * si, px * si + py * c
class Part:
def __init__(self, blk):
self.lib = blk.split('"', 2)[1]
self.ref = re.search(r'\(property "Reference" "([^"]*)"', blk).group(1)
self.val = re.search(r'\(property "Value" "([^"]*)"', blk).group(1)
at = re.search(r'\n\t\t\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', blk)
self.ox, self.oy, self.orot = float(at.group(1)), float(at.group(2)), float(at.group(3) or 0)
# pads: local coords (before footprint rotation) and nets
self.pads = []
for pm in re.finditer(r'\(pad "([^"]*)"[^\n]*\n((?:\t\t\t[^\n]*\n)+?)\t\t\)', blk):
body = pm.group(2)
pa = re.search(r'\(at ([-\d.]+) ([-\d.]+)', body); pn = re.search(r'\(net (?:\d+ )?"([^"]*)"\)', body)
if pa and pn and not pn.group(1).startswith("unconnected"):
self.pads.append((float(pa.group(1)), float(pa.group(2)), pn.group(1)))
# courtyard extent in local coords (rotation 0)
xs, ys = [], []
for cm in re.finditer(r'\(fp_(?:line|rect|poly|circle)[\s\S]*?\(layer "F\.CrtYd"\)[\s\S]*?\n\t\t\)', blk):
cblk = cm.group(0)
for x, y in re.findall(r'\((?:start|end|xy) ([-\d.]+) ([-\d.]+)\)', cblk):
xs.append(float(x)); ys.append(float(y))
if not xs: # fall back to pads + 0.3 mm
for pm in re.finditer(r'\(pad "[^"]*" \w+ \w+\s*\(at ([-\d.]+) ([-\d.]+)[^)]*\)\s*\(size ([-\d.]+) ([-\d.]+)\)', blk):
px, py, sw, sh = map(float, pm.groups())
xs += [px - sw / 2 - .3, px + sw / 2 + .3]; ys += [py - sh / 2 - .3, py + sh / 2 + .3]
if not xs:
xs, ys = [-1, 1], [-1, 1]
self.cw, self.ch = max(xs) - min(xs), max(ys) - min(ys)
self.ccx, self.ccy = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2 # courtyard centre offset
self.x = self.y = None; self.r = 0
def box(self, x, y, r):
"""axis-aligned courtyard box at (x,y) with rotation r (0/90/180/270)."""
cx, cy = rot(self.ccx, self.ccy, r)
w, h = (self.cw, self.ch) if r % 180 == 0 else (self.ch, self.cw)
return (x + cx - w / 2, y + cy - h / 2, x + cx + w / 2, y + cy + h / 2)
def pad_world(self, x, y, r):
return [(x + rot(px, py, r)[0], y + rot(px, py, r)[1], n) for px, py, n in self.pads]
parts = {}
for blk in re.split(r'\n\t\(footprint ', s)[1:]:
p = Part(blk); parts[p.ref] = p
STATE = SRC.with_name("state-clean.json")
if STATE.exists(): # the bridge's own courtyard boxes (what kicad_move_footprint will check)
for f in json.load(open(STATE))["footprints"]:
q = parts.get(f["ref"]); c = f["courtyard"]
if q and f.get("rotation", 0) == 0:
q.cw, q.ch = c["maxX"] - c["minX"], c["maxY"] - c["minY"]
q.ccx, q.ccy = (c["maxX"] + c["minX"]) / 2 - f["x"], (c["maxY"] + c["minY"]) / 2 - f["y"]
print("courtyards taken from the bridge's placement_state")
nets = collections.defaultdict(set)
for p in parts.values():
for _, _, n in p.pads:
nets[n].add(p.ref)
# ---------- helpers ----------
def overlaps(a, b, gap=0.4):
return not (a[2] + gap <= b[0] or b[2] + gap <= a[0] or a[3] + gap <= b[1] or b[3] + gap <= a[1])
def inside(b):
return b[0] >= X0 + MARGIN and b[1] >= Y0 + MARGIN and b[2] <= X1 - MARGIN and b[3] <= Y1 - MARGIN
placed = {} # ref -> box
def place_near(p, x, y, r, reach=12.0, step=0.5):
"""try_place at (x,y), then in widening squares until it fits; returns True/False."""
if try_place(p, x, y, r): return True
for ring in range(1, int(reach / step) + 1):
for dx in range(-ring, ring + 1):
for dy in (-ring, ring):
for rr in (r, (r + 90) % 360):
if try_place(p, x + dx * step, y + dy * step, rr): return True
for dy in range(-ring + 1, ring):
for dx in (-ring, ring):
for rr in (r, (r + 90) % 360):
if try_place(p, x + dx * step, y + dy * step, rr): return True
return False
def try_place(p, x, y, r):
b = p.box(x, y, r)
if not inside(b):
return False
for ref, ob in placed.items():
if overlaps(b, ob):
return False
p.x, p.y, p.r = round(x, 3), round(y, 3), r; placed[p.ref] = b
return True
# ---------- 1b. machine pins (MP) and machine contacts (MC): FIXED by the scaffold pattern ----------
# A molecule's pins and contacts are its plug-in interface (the scaffold pattern: pins 16 mm
# in from the left and right edges, contacts on a 2 mm pitch). They keep the positions and
# rotations the board came with; the plan only occupies their courtyards and never moves them.
big = {}
contacts = sorted([r for r in parts if re.fullmatch(r"MC\d+", r)], key=lambda r: int(r[2:]))
pins = sorted([r for r in parts if re.fullmatch(r"MP\d+", r)], key=lambda r: int(r[2:]))
fixed = {}
for r in contacts + pins:
q = parts[r]; q.x, q.y, q.r = q.ox, q.oy, q.orot
placed[r] = q.box(q.x, q.y, q.r); fixed[r] = (q.x, q.y, q.r)
big[r] = q
# ---------- 1. macro placement by function ----------
cx, cy = (X0 + X1) / 2, (Y0 + Y1) / 2
def nets_of(r): return {n for _, _, n in parts[r].pads}
def find(pred):
return [r for r, p in parts.items() if pred(p)]
mosfets = sorted(find(lambda p: "BSC016" in p.val))
mcu = find(lambda p: "STM32" in p.val)[0]; drv = find(lambda p: "DRV83" in p.val)[0]; buck = find(lambda p: "TPSM" in p.val)[0]
ina = find(lambda p: "INA" in p.val)[0]; hdr = find(lambda p: "STDC14" in p.val)[0]
bulk = sorted(find(lambda p: p.val == "330u")); tvs = find(lambda p: "SMBJ" in p.val)[0]; shunt = find(lambda p: p.val == "R0005")[0]
reg12 = find(lambda p: "ZXTR" in p.val)[0]; reg33 = find(lambda p: "AP2112" in p.val)[0]; sw = find(lambda p: "SKRPACE" in p.val)[0]
xtal = find(lambda p: p.val == "8MHz")[0]; schottky = find(lambda p: p.val == "SS14")[0]
# 3-phase bridge on the right edge: one column, phase A at the bottom, C at the top, high side above low side
def is_high(q): return "+VBAT" in nets_of(q)
phase = collections.defaultdict(list)
for q in mosfets:
for n in nets_of(q):
if n.startswith("/DRV_SH"): phase[n].append(q)
bridge_x = X1 - MARGIN - 3.7 - 0.6 - 8.0 # left of the MC5/MC9/MC11 contact column at x=164
macro = {}
for k, yy in zip(sorted(phase.keys()), (Y1 - 20, cy + 4, Y0 + 22)): # A bottom, B middle, C top
hs, ls = sorted(phase[k], key=lambda q: not is_high(q))
macro[hs] = (bridge_x, yy - 3.5, 0); macro[ls] = (bridge_x, yy + 3.5, 0)
macro[bulk[1]] = (X1 - MARGIN - 12.0, Y0 + 15.5, 0) # bulk caps at the +VBAT rail, under the top contacts, above the bridge
macro[bulk[0]] = (X1 - MARGIN - 12.0 - 14.2, Y0 + 15.5, 0)
macro[tvs] = (X1 - MARGIN - 12.0 - 14.2 - 11.5, Y0 + 15.5, 0) # TVS on the same rail
macro[shunt] = (X1 - MARGIN - 4.2, Y1 - MARGIN - 2.5, 0) # shunt in the low-side return, below the bridge
macro[ina] = (X1 - MARGIN - 4.2 - 9.5, Y1 - MARGIN - 2.2, 0) # sense amp right at the shunt
macro[drv] = (bridge_x - 12.0, cy + 4, 0) # gate driver beside the bridge, mid height
macro[reg12] = (bridge_x - 12.0, cy - 8.5, 0) # 12 V gate supply above the driver
macro[mcu] = (bridge_x - 30.0, cy + 6, 0) # MCU left of the driver
macro[xtal] = (bridge_x - 30.0, cy + 16.5, 0) # crystal under the MCU
macro[reg33] = (bridge_x - 30.0, cy - 5.5, 0) # 3V3 LDO above the MCU
macro[buck] = (X0 + 22, Y0 + 17, 0) # buck near the top-left, fed from the rail
macro[schottky] = (X0 + 22, Y0 + 26, 0)
macro[hdr] = (X0 + 10.5, cy + 2, 0) # debug header on the left edge
macro[sw] = (X0 + 10.5, cy + 13.5, 0) # reset button under it
for r in macro: big[r] = parts[r]
for r, (x, y, rr) in macro.items():
assert place_near(parts[r], x, y, rr, reach=20.0), r
# ---------- 2. attach passives to anchors ----------
small = [p for r, p in parts.items() if r not in big]
def anchor_for(p):
score = collections.Counter()
for _, _, n in p.pads:
if n in ("GND",):
continue
for r in nets[n]:
if r in big and r != p.ref:
score[r] += 3 if not n.startswith("+") else 1
if score:
return score.most_common(1)[0][0]
# power-only or GND-only passive: the rail owner
pn = {n for _, _, n in p.pads}
if "+VBAT" in pn: return bulk[0]
if "+12V" in pn: return reg12
if "+5V" in pn: return buck
return mcu
order = sorted(small, key=lambda p: (-(p.cw * p.ch), p.ref)) # big passives first
unplaced = []
for p in order:
a = parts[anchor_for(p)]
ax, ay = a.x, a.y
# ideal side: towards the anchor pad this part connects to
target = None
for px, py, n in p.pads:
for qx, qy, qn in a.pad_world(a.x, a.y, a.r):
if qn == n and n != "GND":
target = (qx, qy); break
if target: break
tx, ty = target if target else (ax, ay)
step = 0.5; ok = False
for ring in range(1, 140):
cands = []
rad = ring * step
for k in range(max(8, int(2 * math.pi * rad / step))):
ang = 2 * math.pi * k / max(8, int(2 * math.pi * rad / step))
x, y = tx + rad * math.cos(ang), ty + rad * math.sin(ang)
cands.append((abs(x - tx) + abs(y - ty), x, y))
cands.sort()
for _, x, y in cands:
for r in (0, 90):
if try_place(p, x, y, r): ok = True; break
if ok: break
if ok: break
if not ok:
unplaced.append(p.ref)
# ---------- 3. score ----------
def mst_len(points):
if len(points) < 2: return 0.0
left = list(points); tree = [left.pop()]; total = 0.0
while left:
best = None
for i, q in enumerate(left):
d = min(math.hypot(q[0] - t[0], q[1] - t[1]) for t in tree)
if best is None or d < best[0]: best = (d, i)
total += best[0]; tree.append(left.pop(best[1]))
return total
def ratsnest(use_plan):
tot = 0.0
for n, refs in nets.items():
pts = []
for r in refs:
p = parts[r]
pos = (p.x, p.y, p.r) if (use_plan and p.x is not None) else (p.ox, p.oy, p.orot)
pts += [(x, y) for x, y, pn in p.pad_world(*pos) if pn == n]
tot += mst_len(pts)
return tot
plan = {r: {"x": p.x, "y": p.y, "rotation": p.r, "side": "F.Cu", "value": p.val, "anchor": (anchor_for(p) if r not in big else None)} for r, p in parts.items() if p.x is not None and r not in fixed}
report = {"outline": [X0, Y0, X1, Y1], "placed": len(plan), "fixedInterface": fixed, "unplaced": unplaced,
"ratsnest_original_mm": round(ratsnest(False), 1), "ratsnest_plan_mm": round(ratsnest(True), 1),
"macro": {k: (parts[k].x, parts[k].y, parts[k].r) for k in list(big)}}
json.dump({"report": report, "plan": plan}, open(OUT, "w"), indent=1)
print(json.dumps(report, indent=1))