main
John Lauer source at 0.1.25: the whole crate workspace, the flow, the skills and the docs f31cc25
"""A pours plan from the spec, not from the AI's memory of one board.

spec.pours is a list of entries; each becomes zones in the kicad_add_zone shape:
  {"net": "GND", "outline": true, "layers": ["F.Cu","B.Cu"], "priority": 0, "connection": "thermal",
   "thermalGap": 0.3, "thermalBridge": 0.35}
  {"net": "+VBAT", "around": ["MC1","C2","C3","D1"], "margin": 1.0, "layers": ["F.Cu","B.Cu"], "priority": 2,
   "connection": "solid", "exclude": ["U4"]}          # the box around those parts' pads, grown by margin,
                                                          # clipped so it does not reach the excluded parts' boxes
  {"net": "/DRV_SHA", "around": ["Q1.1","Q2.5","MC5"], "margin": 0.6, "layers": ["F.Cu"], "priority": 3, "connection": "solid"}
  {"net": "+VBAT", "polygon": [[x,y],...], "layers": ["B.Cu"], "priority": 2}      # explicit
spec.solidAt: ["C45.2"] pads that get a small solid patch (starved thermal reliefs)
spec.thermalVias: [{"pad": "Q1.5", "net": "+VBAT", "count": 2, "size": 0.8, "drill": 0.4}]
spec.kelvin: {"U6.3": "R32.2", ...} taps get pour keepouts along the routed tap (from the routing plan's kelvin entries)
"""
import json
import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from escboard import Board  # noqa: E402


def pad_box(pads):
    xs = [q[0] for p in pads for poly in p.polys for q in poly]
    ys = [q[1] for p in pads for poly in p.polys for q in poly]
    return [min(xs), min(ys), max(xs), max(ys)]


def rect(x0, y0, x1, y1):
    return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]


def select(board, keys):
    out = []
    for k in keys:
        if "." in k:
            ref, name = k.split(".", 1)
            out += [p for p in board.pads if p.ref == ref and p.name == name]
        else:
            out += [p for p in board.pads if p.ref == k]
    return out


def build(board, spec, plan=None):
    x0, y0, x1, y1 = board.bbox
    edge = float(spec.get("edgeClearance", 0.5))
    zones = []
    for e in spec.get("pours", []):
        z = {k: v for k, v in e.items() if k not in ("around", "margin", "exclude", "polygon", "outline", "inset")}
        z.setdefault("clearance", spec.get("clearance", 0.2)); z.setdefault("minThickness", 0.25)
        if e.get("outline"):
            z["outline"] = True; z["inset"] = float(e.get("inset", edge))
        elif e.get("polygon"):
            z["polygon"] = e["polygon"]
        elif e.get("around"):
            pads = select(board, e["around"])
            if not pads:
                raise SystemExit("pour %s: none of %s found on the board" % (e.get("net"), e["around"]))
            m = float(e.get("margin", 0.8))
            bx = pad_box(pads)
            bx = [max(x0 + edge, bx[0] - m), max(y0 + edge, bx[1] - m), min(x1 - edge, bx[2] + m), min(y1 - edge, bx[3] + m)]
            for ex in e.get("exclude", []):
                xb = pad_box(select(board, [ex]))
                # clip the pour box away from the excluded part on the side where they overlap least
                if not (xb[2] < bx[0] or xb[0] > bx[2] or xb[3] < bx[1] or xb[1] > bx[3]):
                    cuts = [("x1", xb[0] - 1.0, bx[2] - (xb[0] - 1.0)), ("x0", xb[2] + 1.0, (xb[2] + 1.0) - bx[0]), ("y1", xb[1] - 1.0, bx[3] - (xb[1] - 1.0)), ("y0", xb[3] + 1.0, (xb[3] + 1.0) - bx[1])]
                    side, val, _ = min(cuts, key=lambda c: c[2])
                    {"x0": lambda: bx.__setitem__(0, val), "y0": lambda: bx.__setitem__(1, val), "x1": lambda: bx.__setitem__(2, val), "y1": lambda: bx.__setitem__(3, val)}[side]()
            z["polygon"] = rect(*bx)
        else:
            raise SystemExit("pour %s needs outline, polygon or around" % e.get("net"))
        z.setdefault("name", "%s pour" % z.get("net"))
        zones.append(z)
    for key in spec.get("solidAt", []):
        pads = select(board, [key])
        if pads:
            p = pads[0]
            zones.append({"net": p.net, "layers": ["F.Cu" if "F.Cu" in p.layers else "B.Cu"], "polygon": rect(p.x - 0.9, p.y - 0.9, p.x + 0.9, p.y + 0.9), "priority": 1, "connection": "solid",
                          "clearance": spec.get("clearance", 0.2), "minThickness": 0.25, "name": "%s solid joint at %s" % (p.net, key)})
    vias = []
    blockers = []
    if plan:
        for e in plan.get("nets", []):
            for path in e.get("paths", []):
                prev = None
                for w in path:
                    if isinstance(w, str):
                        prev = None; continue
                    if isinstance(w, dict) and "x" not in w:
                        continue
                    pt = (w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1])
                    if prev is not None:
                        blockers.append((prev, pt, e["net"], float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.45))
                    prev = pt

    def clear(x, y, net):
        for (ax, ay), (bx, by), n, need in blockers:
            if n == net:
                continue
            dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy
            t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))
            if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:
                return False
        return True

    for tv in spec.get("thermalVias", []):
        pads = select(board, [tv["pad"]])
        if not pads:
            continue
        p = pads[0]; net = tv.get("net", p.net); n = int(tv.get("count", 2)); placed = 0
        for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)):
            if placed >= n:
                break
            x, y = round(p.x + dx, 2), round(p.y + dy, 2)
            if abs(dx) < p.half_long - 0.4 and abs(dy) < p.half_short - 0.4 or (dx, dy) == (0.0, 0.0):
                if clear(x, y, net):
                    vias.append({"net": net, "width": 0.5, "viaSize": tv.get("size", 0.8), "viaDrill": tv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]}); placed += 1
    for sv in spec.get("stitchVias", []):
        x, y = sv["x"], sv["y"]
        if clear(x, y, sv["net"]):
            vias.append({"net": sv["net"], "width": 0.5, "viaSize": sv.get("size", 0.8), "viaDrill": sv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]})
    keepouts = 0
    if plan and spec.get("kelvin"):
        for e in plan.get("nets", []):
            if not e.get("kelvin"):
                continue
            for path in e["paths"]:
                pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]
                for (ax, ay), (bx, by) in zip(pts, pts[1:]):
                    dx, dy = bx - ax, by - ay; n = math.hypot(dx, dy)
                    if n < 0.05:
                        continue
                    ux, uy = dx / n, dy / n; w = float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.1
                    poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)], [round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]
                    zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True}, "name": "Kelvin tap keepout %s" % e["net"]})
                    keepouts += 1
    return zones, vias, keepouts


def main():
    import argparse
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("board"); ap.add_argument("--spec", required=True); ap.add_argument("--plan"); ap.add_argument("--out", required=True); ap.add_argument("--vias")
    a = ap.parse_args()
    board = Board(a.board); spec = json.load(open(a.spec)); plan = json.load(open(a.plan)) if a.plan else None
    zones, vias, keepouts = build(board, spec, plan)
    json.dump({"zones": zones}, open(a.out, "w"), indent=1)
    if a.vias:
        json.dump({"engine": "adom-boardflow pour plan (thermal and stitching vias)", "nets": vias}, open(a.vias, "w"), indent=1)
    print("%d zones (%d keepouts), %d vias -> %s" % (len(zones), keepouts, len(vias), a.out))
    for z in zones:
        print("  %-10s %-12s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %s %s %s" % (z.get("priority", 0), z.get("connection", "thermal"), "outline" if z.get("outline") else "%d pts" % len(z["polygon"]))))


if __name__ == "__main__":
    main()