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
"""Find legal spots for a few parts near where they should be (the placement-adjustment helper).

    python3 tools/pack_moves.py BOARD.kicad_pcb --wish wishes.json --out moves.json [--gap 0.4]

wishes.json: an ordered object {REF: {"x":.., "y":.., "rot":[0,90], "radius": 4.0, "keepout": [[x0,y0,x1,y1,margin], ...]}}.
For each part in order: candidates on a 0.1 mm grid around the wish, nearest first, each rotation in the
given order; the first whose courtyard clears every other part's courtyard (pad box + 0.25 mm when a part has none) by `gap` (or the part's own "gap"), stays `edge` inside the outline
bbox and avoids the keepout rectangles (grown by their margin) wins. Prints what it found; the output is
the shape kicad_move_footprint and tools/move_footprints.py take.
"""
import argparse, json, math, os, re, sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "demo", "routing", "esc"))
from escboard import Board, rot_kicad  # noqa: E402

def courtyard_local(text, ref):
    """Local (unrotated, anchor-relative) F.CrtYd points of a footprint from the file text, or None."""
    i = text.find('(property "Reference" "%s"' % ref)
    if i < 0:
        return None
    s = text.rfind("\n\t(footprint ", 0, i)
    depth = 0; j = s + 1
    while True:
        c = text[j]
        if c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                break
        j += 1
    block = text[s:j + 1]
    pts = []
    for m in re.finditer(r"\((fp_line|fp_rect|fp_poly|fp_circle|fp_arc)\b(.*?)\n\t\t\)", block, re.S):
        body = m.group(2)
        if '(layer "F.CrtYd")' not in body and '(layer "B.CrtYd")' not in body:
            continue
        for xm in re.finditer(r"\((?:start|end|xy|center|mid) (-?[\d.]+) (-?[\d.]+)\)", body):
            pts.append((float(xm.group(1)), float(xm.group(2))))
        if m.group(1) == "fp_circle":
            c = re.search(r"\(center (-?[\d.]+) (-?[\d.]+)\)", body); e = re.search(r"\(end (-?[\d.]+) (-?[\d.]+)\)", body)
            if c and e:
                r = math.hypot(float(e.group(1)) - float(c.group(1)), float(e.group(2)) - float(c.group(2)))
                cx, cy = float(c.group(1)), float(c.group(2))
                pts += [(cx - r, cy - r), (cx + r, cy + r)]
    return pts or None


def pad_boxes(board):
    boxes = {}
    for p in board.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))
    return boxes

def rel_box(board, ref):
    """The part's pad box relative to its anchor at rotation 0 (undo the file rotation)."""
    fp = board.footprints[ref]
    pts = []
    for p in board.pads:
        if p.ref != ref:
            continue
        for poly in p.polys:
            for (x, y) in poly:
                pts.append(rot_kicad(x - fp["x"], y - fp["y"], -(fp["rot"] or 0)))
    return pts

def placed_box(pts, x, y, rot):
    rp = [rot_kicad(dx, dy, rot) for dx, dy in pts]
    xs = [x + a for a, _ in rp]; ys = [y + c for _, c in rp]
    return [min(xs), min(ys), max(xs), max(ys)]

def clear(box, others, gap):
    for o in others:
        if box[0] - gap < o[2] and o[0] - gap < box[2] and box[1] - gap < o[3] and o[1] - gap < box[3]:
            return False
    return True

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("board"); ap.add_argument("--wish", required=True); ap.add_argument("--out", required=True)
    ap.add_argument("--gap", type=float, default=0.1); ap.add_argument("--edge", type=float, default=1.0)
    a = ap.parse_args()
    b = Board(a.board)
    text = open(a.board, encoding="utf-8").read()
    boxes = pad_boxes(b)
    crt = {}
    for ref, fp in b.footprints.items():
        pts = courtyard_local(text, ref)
        if pts:
            crt[ref] = pts
            boxes[ref] = placed_box(pts, fp["x"], fp["y"], fp["rot"] or 0)
        else:
            bx = boxes.get(ref)
            if bx:
                boxes[ref] = [bx[0] - 0.25, bx[1] - 0.25, bx[2] + 0.25, bx[3] + 0.25]
    wishes = json.load(open(a.wish))
    x0, y0, x1, y1 = b.bbox
    out = {}
    for ref, w in wishes.items():
        pts = crt.get(ref) or [(dx - 0.25, dy - 0.25) for dx, dy in rel_box(b, ref)] + [(dx + 0.25, dy + 0.25) for dx, dy in rel_box(b, ref)]
        others = [bx for r, bx in boxes.items() if r != ref]
        keep = w.get("keepout", [])
        best = None
        R = w.get("radius", 4.0)
        cands = []
        n = int(R / 0.1)
        for i in range(-n, n + 1):
            for j in range(-n, n + 1):
                d = math.hypot(i, j) * 0.1
                if d <= R:
                    cands.append((d, round(w["x"] + i * 0.1, 2), round(w["y"] + j * 0.1, 2)))
        cands.sort()
        for d, x, y in cands:
            for rot in w.get("rot", [0]):
                box = placed_box(pts, x, y, rot)
                if box[0] < x0 + a.edge or box[1] < y0 + a.edge or box[2] > x1 - a.edge or box[3] > y1 - a.edge:
                    continue
                if not clear(box, others, w.get("gap", a.gap)):
                    continue
                if any(box[0] - m < kx1 and kx0 - m < box[2] and box[1] - m < ky1 and ky0 - m < box[3] for (kx0, ky0, kx1, ky1, m) in keep):
                    continue
                best = (x, y, rot, box, d)
                break
            if best:
                break
        if best is None:
            print("%-5s NO SPOT within %.1f mm of (%.1f, %.1f)" % (ref, R, w["x"], w["y"])); continue
        x, y, rot, box, d = best
        boxes[ref] = box
        out[ref] = {"x": x, "y": y, "rotation": rot}
        print("%-5s -> (%.2f, %.2f, %g)  %.2f mm from the wish  box x %.2f..%.2f y %.2f..%.2f" % (ref, x, y, rot, d, box[0], box[2], box[1], box[3]))
    json.dump(out, open(a.out, "w"), indent=1)

if __name__ == "__main__":
    main()