main
John Lauer source at 0.1.25: the whole crate workspace, the flow, the skills and the docs f31cc25
#!/usr/bin/env python3
"""Add copper pours (zones) to a copy of a KiCad board offline, from the same JSON kicad_add_zone takes.

    python3 tools/add_pours.py BOARD.kicad_pcb --pours pours.json [--out FILE] [--drc] [--baseline BOARD.kicad_pcb]

pours.json is {"zones": [ {"net": "+VBAT", "layers": ["F.Cu", "B.Cu"], "polygon": [[x, y], ...] | "outline": true,
"priority": 2, "clearance": 0.2, "minThickness": 0.25, "connection": "thermal" | "solid",
"thermalGap": 0.5, "thermalBridge": 0.5, "name": "..."}, ... ]}. Coordinates are mm, +y down, board frame.
"outline": true uses the Edge.Cuts outline (arcs sampled) inset by the edge clearance; "inset": mm sets it.

The zones are written the way KiCad stores them (no filled polygons: KiCad fills them on load and the
shared DRC refills them), spliced before the closing paren; every other byte is untouched. --drc runs
service-kicad's DRC on the result and, with --baseline, reports NEW errors against the unrouted board.
This is the offline half of the kicad-copper-pours skill: gate the pours here, then land the same JSON
live with kicad_add_zone.
"""
import argparse
import json
import os
import sys
import uuid
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fixture import num  # noqa: E402
from escboard import Board, polygon_area  # noqa: E402
from planes import splice, run_drc  # noqa: E402


def inset_polygon(poly, d):
    """Shrink a convex-ish outline by d toward its centroid (good enough for a rectangle with rounded corners)."""
    if d <= 0:
        return list(poly)
    cx = sum(p[0] for p in poly) / len(poly)
    cy = sum(p[1] for p in poly) / len(poly)
    out = []
    for (x, y) in poly:
        vx, vy = x - cx, y - cy
        n = (vx * vx + vy * vy) ** 0.5 or 1.0
        out.append((x - vx / n * d, y - vy / n * d))
    return out


def keepout_sexpr(board, z):
    """A rule area: no net, the layers, the keepout flags (copperPour by default), the outline."""
    k = z.get("keepout") or {"copperPour": True}
    layers = z.get("layers") or [z["layer"]]
    layer_line = "\t\t(layer \"%s\")\n" % layers[0] if len(layers) == 1 else "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)
    a = lambda b: "not_allowed" if b else "allowed"
    pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in z["_polygon"])
    return (
        "\t(zone\n"
        + ("\t\t(net 0)\n\t\t(net_name \"\")\n" if board.net_format == "number" else "")
        + layer_line
        + "\t\t(uuid \"%s\")\n" % uuid.uuid4()
        + "\t\t(name \"%s\")\n" % z.get("name", "keepout").replace('"', '\\"')
        + "\t\t(hatch edge 0.5)\n"
        + "\t\t(keepout\n\t\t\t(tracks %s)\n\t\t\t(vias %s)\n\t\t\t(pads %s)\n\t\t\t(copperpour %s)\n\t\t\t(footprints %s)\n\t\t)\n" % (a(k.get("tracks")), a(k.get("vias")), a(k.get("pads")), a(k.get("copperPour")), a(k.get("footprints")))
        + "\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n"
        + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts
        + "\t)"
    )


def zone_sexpr(board, z):
    if z.get("type") == "keepout" or z.get("keepout"):
        return keepout_sexpr(board, z)
    net_name = z["net"]
    if board.net_format == "number":
        number = next((n for n, name in board.net_table.items() if name == net_name), None)
        if number is None:
            raise KeyError("net %r is not in the net table" % net_name)
        net_lines = "\t\t(net %d)\n\t\t(net_name \"%s\")\n" % (number, net_name.replace('"', '\\"'))
    else:
        net_lines = board.net_ref_sexpr(net_name)
    layers = z.get("layers") or [z["layer"]]
    if len(layers) == 1:
        layer_line = "\t\t(layer \"%s\")\n" % layers[0]
    else:
        layer_line = "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)
    poly = z["_polygon"]
    pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in poly)
    solid = z.get("connection", "thermal") in ("solid", "full")
    prio = int(z.get("priority", 0) or 0)
    return (
        "\t(zone\n"
        + net_lines
        + layer_line
        + "\t\t(uuid \"%s\")\n" % uuid.uuid4()
        + "\t\t(name \"%s\")\n" % z.get("name", "%s pour" % net_name).replace('"', '\\"')
        + "\t\t(hatch edge 0.5)\n"
        + ("\t\t(priority %d)\n" % prio if prio > 0 else "")
        + ("\t\t(connect_pads yes\n\t\t\t(clearance %s)\n\t\t)\n" if solid else "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n") % num(float(z.get("clearance", 0.2)))
        + "\t\t(min_thickness %s)\n" % num(float(z.get("minThickness", 0.25)))
        + "\t\t(filled_areas_thickness no)\n"
        + "\t\t(fill yes\n\t\t\t(thermal_gap %s)\n\t\t\t(thermal_bridge_width %s)\n\t\t)\n" % (num(float(z.get("thermalGap", 0.5))), num(float(z.get("thermalBridge", 0.5))))
        + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts
        + "\t)"
    )


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("board")
    ap.add_argument("--pours", required=True, help="JSON: {zones: [...]} (the kicad_add_zone shape)")
    ap.add_argument("--out")
    ap.add_argument("--drc", action="store_true")
    ap.add_argument("--baseline", help="board whose DRC errors are inherited (default: the input board)")
    ap.add_argument("--edge", type=float, default=0.5, help="edge clearance used for outline: true")
    a = ap.parse_args(argv)
    src = Path(a.board)
    board = Board(str(src))
    spec = json.load(open(a.pours, encoding="utf-8"))
    zones = spec["zones"] if isinstance(spec, dict) else spec
    outline = list(board.outline)
    if polygon_area(outline) < 0:
        outline.reverse()
    blocks = []
    summary = []
    for z in zones:
        keep = z.get("type") == "keepout" or bool(z.get("keepout"))
        if not keep and z["net"] not in board.nets:
            raise SystemExit("net %r has no pads on this board" % z["net"])
        for l in (z.get("layers") or [z["layer"]]):
            if l not in board.copper_layers:
                raise SystemExit("layer %r is not a copper layer (%s)" % (l, board.copper_layers))
        if z.get("outline"):
            poly = inset_polygon(outline, float(z.get("inset", a.edge)))
        else:
            poly = [(float(p[0]), float(p[1])) for p in z["polygon"]]
            if len(poly) >= 2 and poly[0] == poly[-1]:
                poly.pop()
        if len(poly) < 3:
            raise SystemExit("zone %s needs three or more points" % z["net"])
        z["_polygon"] = poly
        blocks.append(zone_sexpr(board, z))
        summary.append("%s on %s (%.0f mm2, %s)" % (z.get("net") or "keepout", "+".join(z.get("layers") or [z["layer"]]), abs(polygon_area(poly)), "keepout" if keep else "priority %s, %s" % (z.get("priority", 0), z.get("connection", "thermal"))))
    out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-pours.kicad_pcb")
    text = splice(board.text, blocks)
    out.write_text(text, encoding="utf-8", newline="")
    Board(str(out))
    print("%s: %d zones: %s" % (out, len(blocks), "; ".join(summary)))
    if a.drc:
        errors, warnings, unconnected, data = run_drc(out)
        by_type = {}
        for v in data.get("violations", []):
            if v.get("severity") == "error":
                by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1
        line = "drc: %d errors, %d warnings, %d unconnected; error types %s (KiCad %s)" % (errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version"))
        if a.baseline:
            b_err, _, _, b_data = run_drc(Path(a.baseline))
            def sig(v):
                return (v.get("type"), tuple(sorted((i.get("description") or "") for i in v.get("items", []))))
            base = {sig(v) for v in b_data.get("violations", []) if v.get("severity") == "error"}
            new = [v for v in data.get("violations", []) if v.get("severity") == "error" and sig(v) not in base]
            line += "; %d NEW against the baseline (%d inherited)" % (len(new), errors - len(new))
            for v in new[:20]:
                print("  new:", v.get("type"), [(i.get("description") or "")[:60] for i in v.get("items", [])])
            print(line)
            return 0 if (not new and unconnected == 0) else 2
        print(line)
        return 0 if (errors == 0 and unconnected == 0) else 2
    return 0


if __name__ == "__main__":
    sys.exit(main())