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
#!/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())