← Commit history
reference/python/__init__.pydeleted

  
reference/python/__pycache__/copper.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/escboard.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/fixture.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/movefp.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/pack.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/planes.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/pours.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/__pycache__/router.cpython-312.pycdeleted
⋯ 1 unchanged line ⋯
reference/python/analyze.pydeleted−146
@@ -1,146 +0,0 @@-"""Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.--0.1 is deliberately conservative and says so in its hints: it judges the tracks and vias by-IPC-2221's external-layer curve and the pours by presence, connection style and via count, not-by a field solve. A PASS here means "no obvious under-sized conductor and every hot tab has-copper"; a FAIL names the net or the part and what to change. The 0.2 pass (cross-sections-through the filled polygons, IR drop per net) replaces the pour heuristics.-"""-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--MIL2_PER_MM2 = 1550.0031---def ipc2221_amps(width_mm, thickness_um, rise_c, external=True):-    """IPC-2221 track current for a temperature rise: I = k * dT^0.44 * A^0.725 (A in mil^2)."""-    k = 0.048 if external else 0.024-    a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2-    return k * (rise_c ** 0.44) * (a_mil2 ** 0.725)---def rise_for(width_mm, thickness_um, amps, external=True):-    k = 0.048 if external else 0.024-    a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2-    if a_mil2 <= 0 or amps <= 0:-        return 0.0-    return (amps / (k * a_mil2 ** 0.725)) ** (1 / 0.44)---def plan_widths(plan):-    """Per net: the widths used, the via count and the number of entries, from a routing plan."""-    out = {}-    for e in plan.get("nets", []):-        d = out.setdefault(e["net"], {"widths": set(), "vias": 0, "viaSize": e.get("viaSize"), "viaDrill": e.get("viaDrill"), "kelvin": 0})-        d["widths"].add(float(e["width"]))-        if e.get("kelvin"):-            d["kelvin"] += 1-        for path in e.get("paths", []):-            d["vias"] += sum(1 for w in path if isinstance(w, dict) and "layer" in w and "x" in w)-    return out---def analyze_current(plan, spec, zone_state=None):-    """spec.loads: {net: {"amps": continuous A, "peakAmps"?: A, "maxRiseC"?: C}}; spec.copperUm outer thickness (35)."""-    loads = spec.get("loads", {})-    t_um = float(spec.get("copperUm", 35))-    max_rise_default = float(spec.get("maxRiseC", 10))-    widths = plan_widths(plan)-    pours = {}-    for z in (zone_state or {}).get("zones", []):-        if z.get("type") == "copper" and z.get("net"):-            pours.setdefault(z["net"], []).append(z)-    results = []-    ok = True-    for net, ld in loads.items():-        amps = float(ld.get("amps", 0))-        max_rise = float(ld.get("maxRiseC", max_rise_default))-        w = widths.get(net)-        r = {"net": net, "amps": amps, "maxRiseC": max_rise}-        if w is None:-            r.update({"status": "no-copper", "hint": "net %s carries %.1f A but the routing plan has no copper for it" % (net, amps)})-            ok = False-            results.append(r)-            continue-        signal_widths = sorted(x for x in w["widths"] if x > 0.2)-        narrowest = min(signal_widths) if signal_widths else min(w["widths"])-        widest = max(w["widths"])-        rise_narrow = rise_for(narrowest, t_um, amps)-        rise_wide = rise_for(widest, t_um, amps)-        pour_layers = sorted({l for z in pours.get(net, []) for l in z.get("layers", [])})-        pour_area = round(sum(f.get("areaMm2", 0) for z in pours.get(net, []) for f in z.get("fills", [])), 1)-        vias = w["vias"]-        via_amps = 1.5 if (w.get("viaDrill") or 0.3) >= 0.4 else 1.0-        via_capacity = vias * via_amps-        r.update({"narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": round(rise_narrow, 1), "riseAtWidestC": round(rise_wide, 1),-                  "vias": vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area})-        # judgement: a track alone must not be the only path unless it is within the rise; a pour on both outer-        # layers carries the load if it exists and the vias between layers can carry it-        if rise_wide <= max_rise:-            r["status"] = "pass"; r["how"] = "the widest track carries the load within %.0f C" % max_rise-        elif pour_layers and pour_area > 0:-            if len(pour_layers) >= 2 and via_capacity < amps:-                r["status"] = "fail"; r["hint"] = "%s is carried by pours on %s but only %d vias (%.0f A) join the layers for %.1f A: add stitching vias (0.8/0.4 mm, about 1.5 A each)" % (net, "+".join(pour_layers), vias, via_capacity, amps)-                ok = False-            else:-                r["status"] = "pass"; r["how"] = "pour on %s (%.0f mm2 filled) carries the load; tracks are %.2f mm at most" % ("+".join(pour_layers), pour_area, widest)-        else:-            r["status"] = "fail"-            need = None-            for wmm in (0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0):-                if rise_for(wmm, t_um, amps) <= max_rise:-                    need = wmm; break-            r["hint"] = "%s carries %.1f A on a %.2f mm track (%.0f C rise, limit %.0f C) and has no pour: pour it on the outer layers or widen to %s mm" % (net, amps, widest, rise_wide, max_rise, need or ">6")-            ok = False-        results.append(r)-    return ok, results---def analyze_thermal(plan, spec, zone_state=None, board=None):-    """spec.hot: {ref: {"watts": W, "tabNet": net, "tabPad"?: name, "maxThetaCPerW"?: C/W}}. 0.1: copper presence and vias."""-    hot = spec.get("hot", {})-    widths = plan_widths(plan)-    pours = {}-    for z in (zone_state or {}).get("zones", []):-        if z.get("type") == "copper" and z.get("net"):-            pours.setdefault(z["net"], []).append(z)-    results = []-    ok = True-    for ref, h in hot.items():-        net = h.get("tabNet")-        watts = float(h.get("watts", 0))-        r = {"ref": ref, "watts": watts, "tabNet": net}-        zs = pours.get(net, [])-        area = round(sum(f.get("areaMm2", 0) for z in zs for f in z.get("fills", [])), 1)-        layers = sorted({l for z in zs for l in z.get("layers", [])})-        solid = any("solid" in (z.get("name") or "").lower() or z.get("connection") == "solid" for z in zs)-        vias = widths.get(net, {}).get("vias", 0)-        # one square inch of 35 um copper is roughly 40 C/W in still air; scale by area, floor at the package's own theta-        theta = 40.0 / max(area / 645.16, 0.05)-        limit = float(h.get("maxThetaCPerW", spec.get("maxThetaCPerW", 60)))-        r.update({"pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": round(theta, 1), "limitCPerW": limit, "riseEstimateC": round(theta * watts, 1)})-        if not zs:-            r["status"] = "fail"; r["hint"] = "%s dissipates %.1f W into %s but %s has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab" % (ref, watts, net, net)-            ok = False-        elif theta > limit:-            r["status"] = "fail"; r["hint"] = "%s: %.0f mm2 of copper on %s gives about %.0f C/W (limit %.0f): widen the pour or add the other outer layer with vias" % (ref, area, net, theta, limit)-            ok = False-        elif vias == 0 and len(layers) >= 2:-            r["status"] = "fail"; r["hint"] = "%s: the %s pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab" % (ref, net)-            ok = False-        else:-            r["status"] = "pass"; r["how"] = "%.0f mm2 of %s copper on %s, about %.0f C/W, %d vias" % (area, net, "+".join(layers), theta, vias)-        results.append(r)-    return ok, results---if __name__ == "__main__":-    plan = json.load(open(sys.argv[1])); spec = json.load(open(sys.argv[2]))-    zs = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None-    ok, res = analyze_current(plan, spec, zs)-    print(json.dumps({"pass": ok, "results": res}, indent=1))
reference/python/capture.pydeleted−51
@@ -1,51 +0,0 @@-"""The capture recipe: the same picture for every engine, with stage markers.--open:  the board on the desktop's KiCad through the bridge, foreground OpenGL on a test box we own-       (the etiquette pushes a new window back once, so the restore is repeated), maximised, zoom to fit.-start: ab's native monitor recorder; stop: stops it and pulls the MP4 into the run directory.-mark:  writes a labelled timestamp into the run manifest; the split-screen cut aligns engines on them.-"""-import json-import subprocess-import time---def bridge(ai_thread, target, verb, args, timeout=300):-    cmd = ["adom-bridge", "--ai-thread", ai_thread, "--target", target, verb, json.dumps(args)]-    out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout-    try:-        return json.loads(out)-    except Exception:-        return {"status": "parse_error", "raw": out[:400]}---def open_board(ai_thread, target, remote_board, reason):-    r = bridge(ai_thread, target, "kicad_open_board", {"filePath": remote_board, "foreground": True, "foregroundReason": reason})-    hwnd = r.get("hwnd")-    if not hwnd:-        return None, r-    bridge(ai_thread, target, "desktop_ui_window", {"hwnd": hwnd, "action": "maximize", "reason": "boardflow capture: fill the monitor"})-    time.sleep(40)-    bridge(ai_thread, target, "desktop_bring_to_front", {"hwnd": hwnd, "reason": "boardflow capture: keep the PCB editor on screen on the test box"})-    time.sleep(2)-    bridge(ai_thread, target, "kicad_send_key", {"hwnd": hwnd, "key": "ctrl+home"})-    time.sleep(3)-    return hwnd, r---def record_start(ai_thread, target, reason, max_ms=1_800_000):-    r = bridge(ai_thread, target, "desktop_record_start", {"monitor": 0, "audio": False, "fps": 30, "reason": reason, "maxDurationMs": max_ms})-    return r.get("recordingId") or r.get("id"), r---def record_stop(ai_thread, target, recording_id, save_to):-    r = bridge(ai_thread, target, "desktop_record_stop", {"recordingId": recording_id})-    p = r.get("filePath") or r.get("path")-    if not p and r.get("output"):-        try:-            p = json.loads(r["output"]).get("data", {}).get("path")-        except Exception:-            p = None-    p = (p or "").replace("\\", "/")-    pulled = bridge(ai_thread, target, "pull_file", {"filePaths": [p], "saveTo": save_to}, timeout=900) if p else {}-    return p, r, pulled
reference/python/cli.pydeleted−430
@@ -1,430 +0,0 @@-#!/usr/bin/env python3-"""adom-boardflow: the board loop as one command, with the clock, the negotiation and the finish line.--Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and this run.-See SKILL.md for the flow. Every state-changing command needs --ai-thread.-"""-import argparse-import json-import os-import subprocess-import sys-import time-from pathlib import Path--HERE = Path(__file__).resolve().parent-sys.path.insert(0, str(HERE))-from runlog import Run, now, STAGES  # noqa: E402--VERSION = "0.1.0"---def ok(msg, *hints):-    print("OK: " + msg)-    for h in hints:-        if h:-            print("Hint: " + h)---def err(msg, *hints, code=2):-    print("ERROR: " + msg)-    for h in hints:-        if h:-            print("Hint: " + h)-    sys.exit(code)---def sh(args, cwd=None, timeout=7200):-    r = subprocess.run([sys.executable] + args if args and args[0].endswith(".py") else args, cwd=cwd, capture_output=True, text=True, timeout=timeout)-    return r.returncode, r.stdout, r.stderr---def need_thread(a):-    if not a.ai_thread:-        err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", "Re-run with --ai-thread \"<your thread name>\".", code=3)---def load_run(a, must=True):-    r = Run(a.run)-    if must and not r.exists():-        err("no run at %s" % a.run, "Start one: adom-boardflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>")-    return r---def spec_of(r):-    return json.load(open(r.data["spec"], encoding="utf-8"))---# ----------------------------------------------------------------------------------------------def cmd_start(a):-    need_thread(a)-    if not Path(a.board).is_file():-        err("board not found: %s" % a.board)-    if not Path(a.spec).is_file():-        err("spec not found: %s" % a.spec, "docs/spec-example.json on the adom-boardflow page is the shape.")-    r = Run(a.run)-    if r.exists() and not a.force:-        err("a run already exists at %s (engine %s, prompt time %s)" % (a.run, r.data["engine"], r.data["clock"]["promptTime"]), "Pick another --run directory, or --force to start over.")-    Path(a.run).mkdir(parents=True, exist_ok=True)-    board_copy = Path(a.run) / "board-0.kicad_pcb"-    board_copy.write_bytes(Path(a.board).read_bytes())-    prompt_time = a.prompt_time or now()-    r = Run.create(a.run, board_copy, str(Path(a.spec).resolve()), a.engine, prompt_time, a.ai_thread, a.target, a.remote_board)-    ok("run started for engine %s at %s; the clock started at the prompt, %s" % (a.engine, a.run, prompt_time),-       "Next: adom-boardflow plan --run %s (the stages this board needs and who can take each)." % a.run,-       "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.")---def cmd_plan(a):-    r = load_run(a)-    spec = spec_of(r)-    sys.path.insert(0, str(HERE))-    from escboard import Board-    b = Board(r.current_board())-    parked = 0-    x0, y0, x1, y1 = b.bbox-    for ref, fp in b.footprints.items():-        if fp["x"] > x1:-            parked += 1-    fixed = len(spec.get("fixedRefs", []))-    stages = {-        "place": {"engines": ["ai"], "default": "ai", "why": "%d parts parked outside the outline, %d fixed by the interface; the AI places (kicad_placement_state, adom-boardflow place pack/check/land help), the binary checks" % (parked, fixed)},-        "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says --take route=ai"},-        "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},-        "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},-        "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},-        "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},-        "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},-        "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},-        "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},-    }-    r.data["plan"] = {"at": now(), "stages": stages, "board": {"footprints": len(b.footprints), "nets": len(b.nets), "parked": parked, "layers": b.copper_layers}}-    r.save()-    lines = ["%-16s %-12s %s" % (n, "/".join(s["engines"]), s["why"]) for n, s in stages.items()]-    ok("plan for %s: %d footprints, %d nets, %d copper layers, %d parked\n%s" % (r.data["engine"], len(b.footprints), len(b.nets), len(b.copper_layers), parked, "\n".join(lines)),-       "Take a stage yourself with: adom-boardflow take route=ai --run %s (default: binary where it is offered)." % a.run,-       "Every stage stamps the clock; for a stage you take yourself call `adom-boardflow stage start <name>` and `stage end <name>` around your own work.")---def cmd_take(a):-    need_thread(a)-    r = load_run(a)-    for kv in a.decision:-        stage, engine = kv.split("=", 1)-        if stage not in STAGES:-            err("unknown stage %s" % stage, "Stages: " + ", ".join(STAGES))-        r.data["decisions"][stage] = engine-    r.save()-    ok("decisions recorded: %s" % json.dumps(r.data["decisions"]), "They are part of the run manifest and the comparison chart.")---def cmd_stage(a):-    need_thread(a)-    r = load_run(a)-    if a.action == "start":-        r.stage_start(a.name, engine=r.engine_for(a.name), note=a.note)-        ok("stage %s started at %s by %s" % (a.name, now(), r.engine_for(a.name)))-    else:-        result = json.loads(a.result) if a.result else None-        r.stage_end(a.name, result=result, note=a.note)-        ok("stage %s ended at %s" % (a.name, now()), "Minutes so far per stage: %s; %.1f min since the prompt." % (json.dumps(r.stage_minutes()), r.elapsed_minutes()))---# -- place --------------------------------------------------------------------------------------def cmd_place(a):-    r = load_run(a)-    board = r.current_board()-    if a.what == "pack":-        need_thread(a)-        code, out, e = sh([str(HERE / "pack.py"), board, "--wish", a.wish, "--out", a.out, "--edge", "0.5", "--gap", "0.05"])-        if code:-            err("pack failed: %s" % (e or out)[-400:])-        ok("legal spots found:\n" + out.strip(), "Land them live with `adom-boardflow place land --moves %s` or check offline first with `place check --moves %s`." % (a.out, a.out))-    elif a.what == "check":-        need_thread(a)-        n = len(r.data["boards"])-        out_board = str(Path(a.run) / ("board-%d.kicad_pcb" % n))-        code, out, e = sh([str(HERE / "movefp.py"), board, "--out", out_board, "--json", a.moves, "--check"])-        if code and "check:" not in out:-            err("move failed: %s" % (e or out)[-400:])-        drc = sh(["service-kicad", "pcb", "drc", "--format", "json", "--out", out_board + ".drc.json", out_board])-        try:-            v = json.load(open(out_board + ".drc.json")).get("violations", [])-        except Exception:-            v = []-        errs = [x for x in v if x.get("severity") == "error"]-        base = int((spec_of(r).get("inheritedErrors") or 0))-        types = {}-        for x in errs:-            types[x["type"]] = types.get(x["type"], 0) + 1-        if len(errs) > base:-            err("the moved board has %d DRC errors (baseline %d): %s" % (len(errs), base, json.dumps(types)), "Courtyard overlaps or edge violations from the moves; change the wishes and pack again. The board copy stays at %s." % out_board)-        r.add_board(out_board)-        ok("moves applied offline to %s; DRC %d errors, all inherited (%s)" % (out_board, len(errs), json.dumps(types)), "This is now the run's current board. Land the same moves live with `place land`.")-    elif a.what == "land":-        need_thread(a)-        if not r.data.get("target"):-            err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")-        r.stage_start("place-land")-        code, out, e = sh([str(HERE / "land_moves.py"), "--target", r.data["target"], "--board", r.data["remoteBoard"], "--moves", a.moves, "--thread", a.ai_thread, "--log", str(Path(a.run) / "place-land-log.json")])-        r.stage_end("place-land", result={"ok": code == 0})-        if code:-            err("live placement refused: %s" % out[-600:], "The bridge checks courtyards and the outline exactly; read offendingRefs and pack again.")-        ok("placement landed live as one undo step:\n" + out.strip())---# -- route --------------------------------------------------------------------------------------def cmd_route(a):-    need_thread(a)-    r = load_run(a)-    spec = spec_of(r)-    board = r.current_board()-    if r.engine_for("route") == "ai":-        err("route is taken by the AI in this run (take route=ai)", "Write your plan in the plan JSON shape and run `adom-boardflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.")-    cfg = Path(a.run) / "router-config.json"-    json.dump({"wide": spec.get("wideNets", {}), "mid": spec.get("midNets", {}), "planes": spec.get("planes", {}), "kelvin": spec.get("kelvin", {})}, open(cfg, "w"), indent=1)-    plan = str(Path(a.run) / "plan-route.json"); report = str(Path(a.run) / "plan-route-report.json"); log = Path(a.run) / "router.log"-    r.stage_start("route", engine="binary", note="passes=%d" % a.passes)-    with open(log, "w") as lf:-        proc = subprocess.run([sys.executable, str(HERE / "router.py"), board, "--out", plan, "--report", report, "--passes", str(a.passes), "--config", str(cfg)], stdout=lf, stderr=subprocess.STDOUT, text=True)-    rep = json.load(open(report)) if Path(report).is_file() else {"unrouted": [("?", ("?", "router did not write a report"))]}-    unrouted = rep.get("unrouted", [])-    r.stage_end("route", result={"unrouted": len(unrouted), "passes": a.passes, "plan": plan})-    r.data["outcomes"]["route"] = {"plan": plan, "unrouted": len(unrouted), "detail": unrouted[:20]}-    r.save()-    if unrouted:-        blockers = sorted({b for _, (_, why) in unrouted for b in why.replace("blocked by ", "").strip("[]").replace("'", "").split(", ") if why.startswith("blocked")})-        err("routing closed %d connection(s) short after %d passes: %s" % (len(unrouted), a.passes, "; ".join("%s at %s (%s)" % (n, p, why) for n, (p, why) in unrouted[:6])),-            "More passes: --passes %d." % (a.passes + 4),-            "Or go back to placement: the blockers are %s; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again." % (blockers[:8] or "listed in the report"),-            "Or take the stage yourself: `adom-boardflow take route=ai`.")-    ok("routing closed: 0 unrouted after %d passes; plan %s" % (a.passes, plan), "Next: adom-boardflow gate (KiCad DRC offline on the plan).")---# -- pour ---------------------------------------------------------------------------------------def cmd_pour(a):-    need_thread(a)-    r = load_run(a)-    plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")-    out = str(Path(a.run) / "plan-pours.json"); vias = str(Path(a.run) / "plan-vias.json")-    r.stage_start("pour", engine="binary")-    code, o, e = sh([str(HERE / "pourplan.py"), r.current_board(), "--spec", r.data["spec"], "--out", out, "--vias", vias] + (["--plan", plan] if plan else []))-    r.stage_end("pour", result={"ok": code == 0, "pours": out, "vias": vias})-    if code:-        err("pour plan failed: %s" % (e or o)[-400:], "spec.pours entries need net, layers and one of outline, polygon or around.")-    r.data["outcomes"]["pour"] = {"pours": out, "vias": vias}-    r.save()-    ok("pours planned:\n" + o.strip(), "Gate them live: pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `adom-boardflow land pours` does it.")---# -- gate ---------------------------------------------------------------------------------------def cmd_gate(a):-    need_thread(a)-    r = load_run(a)-    plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")-    if not plan:-        err("no routing plan to gate", "Run `adom-boardflow route` or pass --plan yourplan.json.")-    routed = str(Path(a.run) / "board-routed.kicad_pcb"); report = str(Path(a.run) / "gate.json")-    r.stage_start("gate", engine="binary")-    code, o, e = sh([str(HERE / "copper.py"), r.current_board(), plan, "--out", routed, "--drc", "--baseline", r.current_board(), "--report", report])-    g = json.load(open(report)) if Path(report).is_file() else {}-    r.stage_end("gate", result={k: g.get(k) for k in ("newErrors", "inheritedErrors", "unconnected")})-    r.data["outcomes"]["gate"] = {"report": report, "newErrors": g.get("newErrors"), "unconnected": g.get("unconnected"), "routedBoard": routed}-    r.save()-    if g.get("newErrors") or g.get("unconnected"):-        err("gate failed: %s new DRC errors, %s unconnected (inherited %s)" % (g.get("newErrors"), g.get("unconnected"), g.get("inheritedErrors")),-            "New errors: %s" % json.dumps(g.get("newErrorTypes")), "Unconnected: %s" % json.dumps((g.get("unconnectedList") or [])[:6]),-            "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.")-    ok("gate passed: 0 new DRC errors (%s inherited), 0 unconnected; copper %s" % (g.get("inheritedErrors"), json.dumps(g.get("copper"))), "Next: adom-boardflow land route (then land vias, land pours).")---# -- land ---------------------------------------------------------------------------------------def cmd_land(a):-    need_thread(a)-    r = load_run(a)-    if not r.data.get("target"):-        err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")-    T, B = r.data["target"], r.data["remoteBoard"]-    if a.what == "route":-        plan = r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan-        if not r.data.get("outcomes", {}).get("gate", {}).get("newErrors") == 0:-            err("the gate has not passed for this plan", "Run `adom-boardflow gate` first; the finish line needs it.")-        r.stage_start("land-route")-        code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", plan, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-route-log.json")])-        r.stage_end("land-route", result={"ok": code == 0})-        tail = "\n".join(o.strip().splitlines()[-3:])-        if code:-            err("live routing stopped: %s" % tail, "A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.")-        r.data["outcomes"]["land-route"] = tail; r.save()-        ok("routing landed live, one undo step per net:\n" + tail)-    elif a.what == "vias":-        vias = r.data.get("outcomes", {}).get("pour", {}).get("vias")-        r.stage_start("land-vias")-        code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", vias, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-vias-log.json")])-        r.stage_end("land-vias", result={"ok": code == 0})-        ok("thermal and stitching vias landed:\n" + "\n".join(o.strip().splitlines()[-2:]))-    elif a.what == "pours":-        pours = r.data.get("outcomes", {}).get("pour", {}).get("pours")-        r.stage_start("land-pours")-        code, o, e = sh([str(HERE / "land_pours.py"), "--target", T, "--board", B, "--pours", pours, "--local", r.current_board(), "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-pours-log.json")])-        r.stage_end("land-pours", result={"ok": code == 0})-        log = Path(a.run) / "land-pours-log.json"-        L = json.load(open(log)) if log.is_file() else {}-        failed = [c["net"] for c in L.get("calls", []) if c["reply"].get("success") is not True]-        r.data["outcomes"]["land-pours"] = {"before": L.get("before"), "after": L.get("after", {}).get("filledAreaByLayerMm2") if isinstance(L.get("after"), dict) else None, "failed": failed}-        r.save()-        if failed:-            first = next(c for c in L["calls"] if c["reply"].get("success") is not True)-            v = first["reply"].get("violations") or []-            types = {}-            for x in v:-                types[x.get("type")] = types.get(x.get("type"), 0) + 1-            err("pour for %s refused by the preflight: %s" % (failed[0], json.dumps(types)),-                "starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.")-        ok("pours landed: copper per layer before %s, after %s" % (json.dumps(L.get("before")), json.dumps(r.data["outcomes"]["land-pours"]["after"])), "Next: adom-boardflow measure, then analyze current and analyze thermal, then finish.")---# -- measure / analyze / finish -------------------------------------------------------------------def zone_state(r, ai_thread, refill=True):-    from capture import bridge-    return bridge(ai_thread, r.data["target"], "kicad_zone_state", {"filePath": r.data["remoteBoard"], "refill": refill})---def cmd_measure(a):-    need_thread(a)-    r = load_run(a)-    if not r.data.get("target"):-        err("measure reads the live board", "Start the run with --target and --remote-board.")-    r.stage_start("measure")-    zs = zone_state(r, a.ai_thread)-    r.stage_end("measure", result={"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2")})-    json.dump(zs, open(Path(a.run) / "zone-state.json", "w"), indent=1)-    r.data["outcomes"]["measure"] = {"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2"), "coveragePercent": zs.get("filledCoverageByLayerPercent"), "zones": zs.get("zoneCount")}-    r.save()-    ok("copper kept per layer (KiCad's fills): %s; coverage %s" % (json.dumps(zs.get("filledAreaByLayerMm2")), json.dumps(zs.get("filledCoverageByLayerPercent"))), "Next: adom-boardflow analyze current, analyze thermal.")---def cmd_analyze(a):-    need_thread(a)-    r = load_run(a)-    from analyze import analyze_current, analyze_thermal-    plan = json.load(open(r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan))-    spec = spec_of(r)-    zs_path = Path(a.run) / "zone-state.json"-    zs = json.load(open(zs_path)) if zs_path.is_file() else None-    name = "analyze-" + a.what-    r.stage_start(name)-    passed, res = (analyze_current if a.what == "current" else analyze_thermal)(plan, spec, zs)-    r.stage_end(name, result={"pass": passed})-    r.data["outcomes"][name] = {"pass": passed, "results": res}-    r.save()-    lines = ["%s %s: %s" % (x.get("net") or x.get("ref"), x["status"], x.get("how") or x.get("hint")) for x in res]-    if not passed:-        err("%s analysis failed:\n%s" % (a.what, "\n".join(lines)), "Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.")-    ok("%s analysis passed:\n%s" % (a.what, "\n".join(lines)), "0.1 judges tracks by IPC-2221 and pours by presence and vias; the 0.2 pass computes cross-sections through the filled copper.")---def cmd_finish(a):-    need_thread(a)-    r = load_run(a)-    o = r.data.get("outcomes", {})-    missing = []-    if (o.get("gate") or {}).get("newErrors") != 0 or (o.get("gate") or {}).get("unconnected") != 0:-        missing.append("gate: 0 new DRC errors and 0 unconnected (run gate)")-    if "land-route" not in o:-        missing.append("land route")-    if not o.get("land-pours") or o["land-pours"].get("failed"):-        missing.append("land pours (every planned pour accepted)")-    if not o.get("measure"):-        missing.append("measure")-    for k in ("analyze-current", "analyze-thermal"):-        if not (o.get(k) or {}).get("pass"):-            missing.append(k + " (pass)")-    live = None-    if r.data.get("target"):-        from capture import bridge-        live = bridge(a.ai_thread, r.data["target"], "kicad_routing_validate", {"filePath": r.data["remoteBoard"]})-        inherited = int(spec_of(r).get("inheritedErrors") or 0)-        if live.get("unconnected") != 0 or (live.get("errors") or 0) > inherited:-            missing.append("live validate: %s unconnected, %s errors (inherited %d)" % (live.get("unconnected"), live.get("errors"), inherited))-    if missing:-        err("not finished: " + "; ".join(missing), "A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.")-    r.data["clock"]["finishTime"] = now()-    r.data["summary"] = {"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "stageMinutes": r.stage_minutes(), "decisions": r.data["decisions"],-                         "copper": o.get("measure"), "gate": o.get("gate"), "live": {k: live.get(k) for k in ("errors", "unconnected", "warnings")} if live else None,-                         "current": (o.get("analyze-current") or {}).get("pass"), "thermal": (o.get("analyze-thermal") or {}).get("pass")}-    r.save()-    ok("finished: %s from the prompt to a qualified board, %.1f min; stages %s" % (r.data["engine"], r.elapsed_minutes(), json.dumps(r.stage_minutes())),-       "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.",-       "Cut the comparison with gang-takes split mode from the captures and markers in run.json.")---# -- capture ------------------------------------------------------------------------------------def cmd_capture(a):-    need_thread(a)-    r = load_run(a)-    from capture import open_board, record_start, record_stop-    if not r.data.get("target"):-        err("capture needs a desktop target", "Start the run with --target and --remote-board.")-    if a.action == "open":-        hwnd, rep = open_board(a.ai_thread, r.data["target"], r.data["remoteBoard"], a.reason or "boardflow run %s on the test box" % r.data["engine"])-        if not hwnd:-            err("could not open the board: %s" % json.dumps(rep)[:300])-        r.data["captures"].append({"opened": now(), "hwnd": hwnd}); r.save()-        ok("board open, maximised, zoomed to fit (hwnd %s)" % hwnd, "Next: adom-boardflow capture start.")-    elif a.action == "start":-        rid, rep = record_start(a.ai_thread, r.data["target"], a.reason or "boardflow run %s" % r.data["engine"])-        if not rid:-            err("recorder did not start: %s" % json.dumps(rep)[:300])-        r.data["captures"].append({"recordingId": rid, "start": now()}); r.mark("capture-start"); r.save()-        ok("recording %s started at %s" % (rid, now()), "Every stage command writes markers; `capture mark <label>` adds your own.")-    elif a.action == "mark":-        r.mark(a.label); r.save()-        ok("marker %s at %s" % (a.label, now()))-    elif a.action == "stop":-        cap = next((c for c in reversed(r.data["captures"]) if c.get("recordingId") and not c.get("file")), None)-        if not cap:-            err("no running recording in this run")-        p, rep, pulled = record_stop(a.ai_thread, r.data["target"], cap["recordingId"], str(Path(a.run).resolve()))-        cap["stop"] = now(); cap["remoteFile"] = p; cap["file"] = str(Path(a.run) / Path(p).name) if p else None-        r.mark("capture-stop"); r.save()-        ok("recording stopped; %s pulled to %s" % (p, cap["file"]), "Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.")---def cmd_status(a):-    r = load_run(a)-    d = r.data-    o = d.get("outcomes", {})-    ok("run %s, engine %s, %.1f min since the prompt (%s)\nstages (min): %s\ndecisions: %s\nroute: %s unrouted; gate: %s new / %s unconnected; pours: %s; measure: %s; current: %s; thermal: %s; finished: %s" % (-        a.run, d["engine"], r.elapsed_minutes(), d["clock"]["promptTime"], json.dumps(r.stage_minutes()), json.dumps(d["decisions"]),-        (o.get("route") or {}).get("unrouted"), (o.get("gate") or {}).get("newErrors"), (o.get("gate") or {}).get("unconnected"),-        "landed" if o.get("land-pours") and not o["land-pours"].get("failed") else "no", json.dumps((o.get("measure") or {}).get("filledAreaByLayerMm2")),-        (o.get("analyze-current") or {}).get("pass"), (o.get("analyze-thermal") or {}).get("pass"), d["clock"]["finishTime"]))---def main(argv=None):-    ap = argparse.ArgumentParser(prog="adom-boardflow", description=__doc__.split("\n\n")[0])-    ap.add_argument("--ai-thread", help="the name of the AI conversation calling (required for state-changing commands)")-    ap.add_argument("--run", default=os.environ.get("BOARDFLOW_RUN", "./boardflow-run"), help="run directory (default ./boardflow-run or $BOARDFLOW_RUN)")-    ap.add_argument("--version", action="version", version="adom-boardflow " + VERSION)-    sub = ap.add_subparsers(dest="cmd", required=True)-    s = sub.add_parser("start", help="start a run: copies the board, stamps the prompt time"); s.add_argument("--board", required=True); s.add_argument("--spec", required=True); s.add_argument("--engine", required=True); s.add_argument("--prompt-time"); s.add_argument("--target"); s.add_argument("--remote-board"); s.add_argument("--force", action="store_true"); s.set_defaults(f=cmd_start)-    s = sub.add_parser("plan", help="the stages this board needs and who can take each"); s.set_defaults(f=cmd_plan)-    s = sub.add_parser("take", help="record who takes a stage: route=ai pour=binary"); s.add_argument("decision", nargs="+"); s.set_defaults(f=cmd_take)-    s = sub.add_parser("stage", help="stamp a stage you do yourself"); s.add_argument("action", choices=["start", "end"]); s.add_argument("name"); s.add_argument("--note"); s.add_argument("--result"); s.set_defaults(f=cmd_stage)-    s = sub.add_parser("place", help="placement helpers: pack, check, land"); s.add_argument("what", choices=["pack", "check", "land"]); s.add_argument("--wish"); s.add_argument("--out", default="moves.json"); s.add_argument("--moves"); s.set_defaults(f=cmd_place)-    s = sub.add_parser("route", help="the grid router on the current board"); s.add_argument("--passes", type=int, default=5); s.set_defaults(f=cmd_route)-    s = sub.add_parser("pour", help="pours and vias from the spec"); s.add_argument("--plan"); s.set_defaults(f=cmd_pour)-    s = sub.add_parser("gate", help="KiCad DRC offline on the routing plan"); s.add_argument("--plan"); s.set_defaults(f=cmd_gate)-    s = sub.add_parser("land", help="replay live through the bridge"); s.add_argument("what", choices=["route", "vias", "pours"]); s.add_argument("--plan"); s.set_defaults(f=cmd_land)-    s = sub.add_parser("measure", help="filled copper per layer from the live board"); s.set_defaults(f=cmd_measure)-    s = sub.add_parser("analyze", help="current or thermal"); s.add_argument("what", choices=["current", "thermal"]); s.add_argument("--plan"); s.set_defaults(f=cmd_analyze)-    s = sub.add_parser("finish", help="the finish line; refuses until every check passes"); s.set_defaults(f=cmd_finish)-    s = sub.add_parser("capture", help="open, start, mark, stop"); s.add_argument("action", choices=["open", "start", "mark", "stop"]); s.add_argument("label", nargs="?"); s.add_argument("--reason"); s.set_defaults(f=cmd_capture)-    s = sub.add_parser("status", help="where the run stands"); s.set_defaults(f=cmd_status)-    a = ap.parse_args(argv)-    a.f(a)---if __name__ == "__main__":-    main()
reference/python/copper.pydeleted−481
@@ -1,481 +0,0 @@-#!/usr/bin/env python3-"""Apply a routing plan OFFLINE into a copy of the board and gate it with the shared KiCad DRC.--    python3 demo/routing/esc/write_copper.py BOARD-planes.kicad_pcb PLAN.json [--out FILE]-                                             [--no-fill-planes] [--drc] [--baseline BOARD.kicad_pcb]-                                             [--report drc-report.json]--The plan is the list of `kicad_route_net` calls the router wrote (see ai_router.py): each entry-is {"net", "width", "points": [...]} or {"net", "width", "paths": [[...], ...]} plus optional-"viaSize" / "viaDrill". Waypoints are "REF.PAD" (the pad centre), [x, y], or {"x", "y",-"layer": "B.Cu"} / {"pad": "REF.PAD", "layer": ...}; a layer marker means a through via AT that-point and every following segment on the new layer, exactly as rust/crates/kicad-core/src/pcb.rs-`plan` reads it. Segments and vias are written as KiCad 10 s-expressions with the board's net-reference, a fresh uuid each, and spliced in before the closing paren.--Plane zones (GND on In1.Cu, +3V3 on In2.Cu from add_planes.py) have no fill in the file and the-shared `service-kicad pcb drc` does not refill zones, so by default this script also writes a-raster fill for every zone: overlapping 0.1 mm strips covering the outline (0.5 mm edge clearance)-minus every other-net through pad, via and hole expanded by the 0.2 mm clearance and the grid-slop. KiCad's own fill on the live board replaces it (thermal reliefs and all); here it lets the-DRC count each plane-net via as connected. --no-fill-planes leaves the zones unfilled.----drc runs `service-kicad pcb drc` on the written file and prints errors, warnings, unconnected-items and the violation types. With --baseline, errors already present on the unrouted board-(same type, description and item positions) are listed separately as inherited, the way the-bridge's route_net compares before/after; the gate is zero NEW errors and zero unconnected.-"""-import argparse-import json-import math-import os-import subprocess-import sys-import tempfile-import uuid-from collections import Counter-from pathlib import Path--import numpy as np--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board, Grid, fmt  # noqa: E402--CLEARANCE = 0.2-EDGE_CLEARANCE = 0.5-SLOP = 0.1---# ----------------------------------------------------------------------------- plan -> copper--def _point(spec, board):-    """(x, y, pad, explicit_layer) for one waypoint, matching pcb.rs resolve_point."""-    layer = None-    pad = None-    if isinstance(spec, str):-        pad = board.pad_by_key(spec)-        if pad is None:-            raise ValueError("unknown pad %r" % spec)-        return pad.x, pad.y, pad, None-    if isinstance(spec, dict):-        layer = spec.get("layer")-        if spec.get("pad"):-            pad = board.pad_by_key(str(spec["pad"]))-            if pad is None:-                raise ValueError("unknown pad %r" % spec["pad"])-            return pad.x, pad.y, pad, layer-        return float(spec["x"]), float(spec["y"]), None, layer-    if isinstance(spec, (list, tuple)) and len(spec) == 2:-        return float(spec[0]), float(spec[1]), None, None-    raise ValueError("bad waypoint %r" % (spec,))---def plan_to_copper(board, entry):-    """One plan entry -> (segments, vias): segments (x0,y0,x1,y1,layer,width), vias (x,y,size,drill)."""-    net = entry["net"]-    if net not in board.nets:-        raise ValueError("unknown net %r" % net)-    width = float(entry.get("width", 0.25))-    size = float(entry.get("viaSize", 0.6))-    drill = float(entry.get("viaDrill", 0.3))-    paths = entry.get("paths") or [entry["points"]]-    segments, vias = [], []-    for path in paths:-        if not 2 <= len(path) <= 512:-            raise ValueError("path needs 2..512 waypoints (%s)" % net)-        resolved = [_point(p, board) for p in path]-        for x, y, pad, _ in resolved:-            if pad is not None and pad.net != net:-                raise ValueError("pad %s is on %s, not %s" % (pad.key, pad.net, net))-        layer = entry.get("layer") or resolved[0][3] or "F.Cu"-        for i, (x, y, pad, explicit) in enumerate(resolved):-            nxt = explicit or layer-            if i > 0:-                px, py = resolved[i - 1][0], resolved[i - 1][1]-                if (px, py) != (x, y):-                    segments.append((px, py, x, y, layer, width))-            if nxt != layer:-                vias.append((x, y, size, drill))-                layer = nxt-            if pad is not None and layer not in pad.layers:-                raise ValueError("pad %s has no copper on %s" % (pad.key, layer))-    return segments, vias---def segment_sexpr(board, s, net):-    x0, y0, x1, y1, layer, w = s-    return ("\t(segment\n\t\t(start %s %s)\n\t\t(end %s %s)\n\t\t(width %s)\n\t\t(layer \"%s\")\n%s\t\t(uuid \"%s\")\n\t)"-            % (fmt(x0), fmt(y0), fmt(x1), fmt(y1), fmt(w), layer, board.net_ref_sexpr(net), uuid.uuid4()))---def via_sexpr(board, v, net):-    x, y, size, drill = v-    return ("\t(via\n\t\t(at %s %s)\n\t\t(size %s)\n\t\t(drill %s)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n%s\t\t(uuid \"%s\")\n\t)"-            % (fmt(x), fmt(y), fmt(size), fmt(drill), board.net_ref_sexpr(net), uuid.uuid4()))---def splice(text, blocks):-    trimmed = text.rstrip()-    idx = trimmed.rfind(")")-    body = text[:idx].rstrip()-    nl = "\r\n" if "\r\n" in text[:2000] else "\n"-    return body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl---# ----------------------------------------------------------------------------- plane raster fill--def zone_fill_polygons(board, zone_net, zone_layer, all_vias_by_net, grid=None):-    """ONE fractured polygon per connected region of the plane (KiCad's own fill format: the outer-    contour with every hole spliced in through a zero-width slit). KiCad does not connect-    overlapping sub-polygons of one zone to each other, so a fill made of strips would be a set-    of islands; a single polygon is one island the way a real fill is.--    all_vias_by_net: {net: [(x, y, size, drill), ...]} for every via the plan adds."""-    grid = grid or Grid(board.bbox)-    free = grid.outline_inside(board.outline, EDGE_CLEARANCE + SLOP)-    block = np.zeros((grid.H, grid.W), dtype=bool)-    by_clearance = {}   # pads with a local clearance override are dilated by their own value-    for p in board.pads:-        if not p.thru or p.net == zone_net:-            continue-        target = block if p.clearance <= CLEARANCE else by_clearance.setdefault(p.clearance, np.zeros((grid.H, grid.W), dtype=bool))-        for poly in p.polys:-            grid.poly_mask(poly, target)-        if p.drill:-            jj, ii = grid.disk_cells((p.x, p.y), p.drill / 2.0)-            target[jj, ii] = True-    for net, vias in all_vias_by_net.items():-        if net == zone_net:-            continue-        for x, y, size, drill in vias:-            jj, ii = grid.disk_cells((x, y), size / 2.0)-            block[jj, ii] = True-    block = grid.dilate(block, (CLEARANCE + SLOP) / grid.res)-    for clr, mask in by_clearance.items():-        block |= grid.dilate(mask, (clr + SLOP) / grid.res)-    free &= ~block-    loops = _trace_boundaries(free)-    outers = [lp for lp in loops if _signed_area(lp) > 0]-    holes = [lp for lp in loops if _signed_area(lp) < 0]-    polys = []-    for outer in outers:-        ob = _bbox(outer)-        mine = []-        for h in holes:-            hb = _bbox(h)-            if hb[0] < ob[0] or hb[1] < ob[1] or hb[2] > ob[2] or hb[3] > ob[3]:-                continue-            if _point_in_poly(h[0][0] + 1e-6, h[0][1] + 1e-6, outer):-                mine.append(h)-        poly = _fracture(outer, mine)-        polys.append([(grid.x0 + x * grid.res, grid.y0 + y * grid.res) for x, y in poly])-    return polys---def _trace_boundaries(free):-    """Boundary loops of a binary raster as lattice polygons (cell-corner coordinates, in cells).-    Region on the right of the direction of travel; outer loops and holes get opposite signed areas."""-    H, W = free.shape-    padded = np.zeros((H + 2, W + 2), dtype=bool)-    padded[1:-1, 1:-1] = free-    f = padded-    # directed edges around free cells whose neighbour across that side is not free-    edges = []-    jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[0:-2, 1:-1])   # top side: TL -> TR-    edges += [((i - 0.5, j - 0.5), (i + 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]-    jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 2:])     # right side: TR -> BR-    edges += [((i + 0.5, j - 0.5), (i + 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]-    jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[2:, 1:-1])     # bottom side: BR -> BL-    edges += [((i + 0.5, j + 0.5), (i - 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]-    jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 0:-2])   # left side: BL -> TL-    edges += [((i - 0.5, j + 0.5), (i - 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]-    out = {}-    for a, b in edges:-        out.setdefault(a, []).append(b)-    loops = []-    while out:-        a = next(iter(out))-        b = out[a].pop()-        if not out[a]:-            del out[a]-        loop = [a, b]-        prev = (b[0] - a[0], b[1] - a[1])-        while True:-            cur = loop[-1]-            cands = out.get(cur)-            if not cands:-                break-            if len(cands) == 1:-                nxt = cands.pop()-            else:-                # a pinch point: prefer the right turn (toward the region), then straight, then left-                right = (-prev[1], prev[0])-                pick = None-                for want in (right, prev, (prev[1], -prev[0])):-                    for c in cands:-                        if (c[0] - cur[0], c[1] - cur[1]) == want:-                            pick = c-                            break-                    if pick is not None:-                        break-                nxt = pick if pick is not None else cands[-1]-                cands.remove(nxt)-            if not cands:-                del out[cur]-            prev = (nxt[0] - cur[0], nxt[1] - cur[1])-            if nxt == loop[0]:-                break-            loop.append(nxt)-        # drop collinear vertices-        simp = []-        n = len(loop)-        for k in range(n):-            p0, p1, p2 = loop[k - 1], loop[k], loop[(k + 1) % n]-            if (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) != 0:-                simp.append(p1)-        if len(simp) >= 4:-            loops.append(simp)-    return loops---def _signed_area(poly):-    a = 0.0-    for k in range(len(poly)):-        x0, y0 = poly[k]-        x1, y1 = poly[(k + 1) % len(poly)]-        a += x0 * y1 - x1 * y0-    return a / 2.0---def _bbox(poly):-    xs = [p[0] for p in poly]-    ys = [p[1] for p in poly]-    return min(xs), min(ys), max(xs), max(ys)---def _point_in_poly(x, y, poly):-    inside = False-    n = len(poly)-    for k in range(n):-        ax, ay = poly[k]-        bx, by = poly[(k + 1) % n]-        if (ay > y) != (by > y):-            if x < ax + (y - ay) * (bx - ax) / (by - ay):-                inside = not inside-    return inside---def _fracture(outer, holes):-    """Splice every hole into the outer contour through a horizontal zero-width slit to the left,-    holes in order of their left edge so a slit only ever meets contour that is already part of-    the polygon. The slit leaves the hole from the middle of its leftmost vertical edge, so it never-    hits a lattice vertex."""-    poly = list(outer)--    def leftmost_edge(h):-        best = None-        n = len(h)-        for k in range(n):-            a, b = h[k], h[(k + 1) % n]-            if a[0] == b[0] and (best is None or a[0] < best[0]):-                best = (a[0], k)-        return best--    order = sorted(range(len(holes)), key=lambda k: leftmost_edge(holes[k])[0])-    for hk in order:-        h = holes[hk]-        x_h, k = leftmost_edge(h)-        a, b = h[k], h[(k + 1) % len(h)]-        ym = (a[1] + b[1]) / 2.0-        if abs(b[1] - a[1]) >= 2:-            ym = a[1] + (0.5 if b[1] > a[1] else -0.5)  # a whole cell from the vertex: never a lattice vertex-        start = (x_h, ym)-        # hole contour starting at the slit point-        hole = [start] + h[k + 1:] + h[:k + 1] + [start]-        # nearest vertical edge of the polygon to the left of the slit point at height ym-        hit = None-        for e in range(len(poly)):-            p0, p1 = poly[e], poly[(e + 1) % len(poly)]-            if p0[0] == p1[0] and p0[0] < x_h and min(p0[1], p1[1]) < ym < max(p0[1], p1[1]):-                if hit is None or p0[0] > hit[0]:-                    hit = (p0[0], e)-        if hit is None:-            continue  # nothing to the left inside this outer: leave the hole out (cannot happen for a hole)-        xe, e = hit-        joint = (xe, ym)-        poly = poly[:e + 1] + [joint] + hole + [joint] + poly[e + 1:]-    return poly---def zones_of(board):-    """[(node, net, layer)] for every top-level zone."""-    out = []-    for z in board.root.find_all("zone"):-        n = z.find("net")-        net = n.value() if n is not None else ""-        if board.net_format == "number" and n is not None:-            try:-                net = board.net_table.get(int(n.atom(1)), net)-            except (TypeError, ValueError):-                pass-        nn = z.find("net_name")-        if nn is not None:-            net = nn.value()-        layer = z.find("layer").value() if z.find("layer") is not None else None-        out.append((z, net, layer))-    return out---def fill_zones(board, text, vias_by_net):-    """Return the board text with a filled_polygon list appended inside every zone."""-    grid = Grid(board.bbox)-    edits = []-    stats = {}-    for node, net, layer in zones_of(board):-        if not net or not layer:-            continue-        polys = zone_fill_polygons(board, net, layer, vias_by_net, grid)-        blocks = []-        for poly in polys:-            pts = " ".join("(xy %s %s)" % (fmt(x), fmt(y)) for x, y in poly)-            blocks.append("\t\t(filled_polygon\n\t\t\t(layer \"%s\")\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % (layer, pts))-        # insert before the zone's closing paren-        edits.append((node.end - 1, node.end - 1, "".join(blocks) + "\t"))-        # a raster fill has no thermal spokes: connect pads solidly in this gate copy so the DRC-        # does not report starved thermals on the through pads inside the plane-        cp = node.find("connect_pads")-        if cp is not None and cp.atom(1) not in ("yes", "no", "thru_hole_only"):-            edits.append((cp.start, cp.start + len("(connect_pads"), "(connect_pads yes"))-        stats[net] = len(polys)-    edits.sort()-    out = []-    pos = 0-    for at, end, ins in edits:-        out.append(text[pos:at])-        out.append(ins)-        pos = end-    out.append(text[pos:])-    return "".join(out), stats---# ----------------------------------------------------------------------------- DRC--def run_drc(path):-    with tempfile.TemporaryDirectory() as tmp:-        report = Path(tmp) / "drc.json"-        proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],-                              capture_output=True, text=True, timeout=900)-        if proc.returncode or not report.is_file():-            raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-600:], proc.stderr[-600:]))-        return json.loads(report.read_text(encoding="utf-8"))---def fingerprint(v):-    return json.dumps([v.get("type"), v.get("severity"), v.get("description"),-                       sorted(json.dumps(i.get("pos"), sort_keys=True) for i in v.get("items", []))], sort_keys=True)---def summarize(data, baseline=None):-    viol = data.get("violations", [])-    errors = [v for v in viol if v.get("severity") == "error"]-    warnings = [v for v in viol if v.get("severity") == "warning"]-    inherited = []-    new = errors-    if baseline is not None:-        existing = Counter(fingerprint(v) for v in baseline.get("violations", []) if v.get("severity") == "error")-        new, inherited = [], []-        for v in errors:-            k = fingerprint(v)-            if existing.get(k, 0) > 0:-                existing[k] -= 1-                inherited.append(v)-            else:-                new.append(v)-    unconnected = data.get("unconnected_items", [])-    # isolated_copper warnings on a raster-filled plane are the strips themselves (each filled-    # polygon that touches no pad is an "island" to KiCad); the live fill has none of them-    fill_artifacts = [v for v in warnings if v.get("type") == "isolated_copper"]-    warnings = [v for v in warnings if v.get("type") != "isolated_copper"]-    return {-        "errors": len(errors), "newErrors": len(new), "inheritedErrors": len(inherited),-        "warnings": len(warnings), "unconnected": len(unconnected),-        "rasterFillIslandWarnings": len(fill_artifacts),-        "errorTypes": dict(Counter(v.get("type") for v in errors)),-        "newErrorTypes": dict(Counter(v.get("type") for v in new)),-        "warningTypes": dict(Counter(v.get("type") for v in warnings)),-        "newErrorList": [{"type": v.get("type"), "description": v.get("description"),-                          "items": [i.get("description") for i in v.get("items", [])],-                          "pos": [i.get("pos") for i in v.get("items", [])]} for v in new],-        "unconnectedList": [[i.get("description") for i in u.get("items", [])] for u in unconnected],-        "kicadVersion": data.get("kicad_version"),-    }---# ----------------------------------------------------------------------------- main--def apply(board, plan, fill_planes=True):-    """Return (text, stats) for the board text with the plan's copper (and optional fills)."""-    entries = plan["nets"] if isinstance(plan, dict) else plan-    blocks = []-    vias_by_net = {}-    n_seg = n_via = 0-    length = 0.0-    widths = Counter()-    for entry in entries:-        segs, vias = plan_to_copper(board, entry)-        for s in segs:-            blocks.append(segment_sexpr(board, s, entry["net"]))-            length += math.hypot(s[2] - s[0], s[3] - s[1])-            widths[s[5]] += 1-        for v in vias:-            blocks.append(via_sexpr(board, v, entry["net"]))-            vias_by_net.setdefault(entry["net"], []).append(v)-        n_seg += len(segs)-        n_via += len(vias)-    text = splice(board.text, blocks)-    stats = {"entries": len(entries), "nets": len({e["net"] for e in entries}), "segments": n_seg, "vias": n_via,-             "copperLengthMm": round(length, 2), "segmentsByWidth": {str(k): v for k, v in sorted(widths.items())}}-    if fill_planes:-        text, fills = fill_zones(board, text, vias_by_net)-        stats["zoneFillPolygons"] = fills-    return text, stats---def main(argv=None):-    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])-    ap.add_argument("board", help="the board WITH planes (add_planes.py output)")-    ap.add_argument("plan", help="ai_router.py plan JSON")-    ap.add_argument("--out", help="output .kicad_pcb (default: <plan>-routed.kicad_pcb beside the plan)")-    ap.add_argument("--no-fill-planes", action="store_true")-    ap.add_argument("--drc", action="store_true")-    ap.add_argument("--baseline", help="unrouted board whose DRC errors are inherited (default: the input board)")-    ap.add_argument("--report", help="write the DRC summary JSON here")-    a = ap.parse_args(argv)-    board = Board(a.board)-    plan = json.load(open(a.plan, encoding="utf-8"))-    text, stats = apply(board, plan, fill_planes=not a.no_fill_planes)-    out = Path(a.out) if a.out else Path(a.plan).with_name(Path(a.plan).stem + "-routed.kicad_pcb")-    out.write_text(text, encoding="utf-8", newline="")-    Board(str(out))-    print("%s: %s" % (out, json.dumps(stats)))-    if a.drc:-        base = run_drc(a.baseline or a.board)-        data = run_drc(out)-        s = summarize(data, base)-        s["copper"] = stats-        s["file"] = str(out)-        print("drc: %d errors (%d new, %d inherited), %d warnings (+%d raster-fill island warnings), %d unconnected; new error types %s; warning types %s; KiCad %s" % (-            s["errors"], s["newErrors"], s["inheritedErrors"], s["warnings"], s["rasterFillIslandWarnings"], s["unconnected"],-            json.dumps(s["newErrorTypes"], sort_keys=True), json.dumps(s["warningTypes"], sort_keys=True), s["kicadVersion"]))-        for v in s["newErrorList"][:40]:-            print("  NEW %s: %s %s" % (v["type"], v["description"], v["items"]))-        for u in s["unconnectedList"][:40]:-            print("  UNCONNECTED %s" % (u,))-        if a.report:-            Path(a.report).write_text(json.dumps(s, indent=1) + "\n", encoding="utf-8")-        return 0 if (s["newErrors"] == 0 and s["unconnected"] == 0) else 2-    return 0---if __name__ == "__main__":-    sys.exit(main())
reference/python/escboard.pydeleted−532
@@ -1,532 +0,0 @@-#!/usr/bin/env python3-"""Board model, pad geometry and grid rasterisation shared by the ESC routing scripts.--Reads a KiCad 10 `.kicad_pcb` with the span-keeping tokenizer from-tools/make_placement_fixture.py and turns it into what a grid router needs:--  * every copper pad in BOARD coordinates (footprint position + rotation, KiCad's-    y-down convention: x' = x*cos + y*sin, y' = -x*sin + y*cos), with its outline as-    one or more polygons (rect/roundrect as the rectangle, oval as a stadium, circle as-    a 32-gon, custom pads as their anchor plus every gr_poly/gr_rect/gr_circle primitive)-  * the Edge.Cuts outline as one closed polygon (arcs sampled)-  * the net format (KiCad 10 boards carry `(net "NAME")` on pads and no net table)-  * a 0.1 mm grid with conservative rasterisers (a cell is copper if any part of the-    shape touches the cell square) and a disk dilation for clearance maps--Python 3 plus numpy. Nothing here writes a file.-"""-import math-import os-import sys--import numpy as np--_TOOLS = os.path.dirname(os.path.abspath(__file__))-sys.path.insert(0, os.path.abspath(_TOOLS))-from fixture import parse, reference_of, num  # noqa: E402--RES = 0.1  # mm per grid cell-F_CU, B_CU = 0, 1-LAYER_NAMES = {F_CU: "F.Cu", B_CU: "B.Cu"}-LAYER_IDS = {"F.Cu": F_CU, "B.Cu": B_CU}---# ----------------------------------------------------------------------------- geometry--def rot_kicad(px, py, deg):-    """KiCad's board rotation of a local offset (y-down, positive angle = counterclockwise on screen)."""-    a = math.radians(deg)-    c, s = math.cos(a), math.sin(a)-    return px * c + py * s, -px * s + py * c---def circle_poly(cx, cy, r, n=32):-    return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]---def stadium_poly(sx, sy, n=10):-    """Oval pad of size sx x sy centred at 0 (local frame)."""-    if sx >= sy:-        r = sy / 2.0-        d = (sx - sy) / 2.0-        pts = []-        for k in range(n + 1):-            a = -math.pi / 2 + math.pi * k / n-            pts.append((d + r * math.cos(a), r * math.sin(a)))-        for k in range(n + 1):-            a = math.pi / 2 + math.pi * k / n-            pts.append((-d + r * math.cos(a), r * math.sin(a)))-        return pts-    pts = stadium_poly(sy, sx, n)-    return [(y, x) for x, y in pts]---def arc_points(p0, pm, p1, max_step=0.35):-    """Sample a KiCad three-point arc (start, mid, end) into a polyline including both ends."""-    (x0, y0), (xm, ym), (x1, y1) = p0, pm, p1-    ax, ay = xm - x0, ym - y0-    bx, by = x1 - x0, y1 - y0-    d = 2.0 * (ax * by - ay * bx)-    if abs(d) < 1e-9:-        return [p0, p1]-    a2 = ax * ax + ay * ay-    b2 = bx * bx + by * by-    ux = (by * a2 - ay * b2) / d-    uy = (ax * b2 - bx * a2) / d-    cx, cy = x0 + ux, y0 + uy-    r = math.hypot(ux, uy)-    t0 = math.atan2(y0 - cy, x0 - cx)-    tm = math.atan2(ym - cy, xm - cx)-    t1 = math.atan2(y1 - cy, x1 - cx)--    def ccw(a, b):-        return (b - a) % (2 * math.pi)--    if ccw(t0, tm) <= ccw(t0, t1):-        sweep = ccw(t0, t1)-    else:-        sweep = -ccw(t1, t0)-    n = max(2, int(math.ceil(abs(sweep) * r / max_step)))-    return [(cx + r * math.cos(t0 + sweep * k / n), cy + r * math.sin(t0 + sweep * k / n)) for k in range(n + 1)]---def chain_polylines(pieces, tol=0.01):-    """Join open polylines end to end into one closed loop (the board outline)."""-    pieces = [list(p) for p in pieces if len(p) >= 2]-    if not pieces:-        return []-    loop = pieces.pop(0)-    while pieces:-        end = loop[-1]-        best = None-        for idx, p in enumerate(pieces):-            if math.dist(end, p[0]) < tol:-                best = (idx, False)-                break-            if math.dist(end, p[-1]) < tol:-                best = (idx, True)-                break-        if best is None:-            raise ValueError("Edge.Cuts outline is not one closed loop (gap at %s)" % (end,))-        idx, rev = best-        p = pieces.pop(idx)-        if rev:-            p.reverse()-        loop.extend(p[1:])-    if math.dist(loop[0], loop[-1]) < tol:-        loop.pop()-    return loop---def polygon_area(poly):-    a = 0.0-    for i in range(len(poly)):-        x0, y0 = poly[i]-        x1, y1 = poly[(i + 1) % len(poly)]-        a += x0 * y1 - x1 * y0-    return a / 2.0---# ----------------------------------------------------------------------------- pads--class Pad:-    __slots__ = ("ref", "name", "net", "x", "y", "angle", "shape", "size", "drill", "thru", "layers",-                 "polys", "key", "fp_x", "fp_y", "long_axis", "half_long", "half_short", "uuid", "polys_centre", "clearance")--    def __init__(self):-        self.polys = []--    def __repr__(self):-        return "Pad(%s %s at %.3f,%.3f)" % (self.key, self.net, self.x, self.y)--    @property-    def min_dim(self):-        return 2.0 * self.half_short--    def local_extent(self):-        """Extents of the pad copper in its own (rotated) frame: (half_long, half_short, axis unit vector)."""-        return self.half_long, self.half_short, self.long_axis---def _pad_local_polys(pad_node, shape, sx, sy):-    """Polygons of the pad shape in the pad-local unrotated frame, centred on the pad origin."""-    polys = []-    if shape in ("rect", "roundrect", "trapezoid", "chamfer"):-        polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])-    elif shape == "oval":-        polys.append(stadium_poly(sx, sy))-    elif shape == "circle":-        polys.append(circle_poly(0.0, 0.0, sx / 2.0))-    elif shape == "custom":-        opts = pad_node.find("options")-        anchor = "rect"-        if opts is not None and opts.find("anchor") is not None:-            anchor = opts.find("anchor").value()-        if anchor == "circle":-            polys.append(circle_poly(0.0, 0.0, sx / 2.0))-        else:-            polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])-        prims = pad_node.find("primitives")-        if prims is not None:-            for prim in prims.items:-                if not hasattr(prim, "tag"):-                    continue-                if prim.tag == "gr_poly":-                    pts = prim.find("pts")-                    if pts is not None:-                        poly = [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]-                        w = prim.find("width")-                        wv = w.number(1) if w is not None else 0.0-                        if wv and wv > 0:-                            # stroked outline: widen by half the stroke (conservative bbox growth)-                            poly = _grow_poly(poly, wv / 2.0)-                        polys.append(poly)-                elif prim.tag == "gr_rect":-                    s, e = prim.find("start"), prim.find("end")-                    if s is not None and e is not None:-                        x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)-                        polys.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])-                elif prim.tag == "gr_circle":-                    c, e = prim.find("center"), prim.find("end")-                    if c is not None and e is not None:-                        r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))-                        w = prim.find("width")-                        r += (w.number(1) if w is not None else 0.0) / 2.0-                        polys.append(circle_poly(c.number(1), c.number(2), r))-                elif prim.tag == "gr_line":-                    s, e = prim.find("start"), prim.find("end")-                    w = prim.find("width")-                    if s is not None and e is not None:-                        hw = (w.number(1) if w is not None else 0.1) / 2.0-                        polys.append(_thick_line_poly((s.number(1), s.number(2)), (e.number(1), e.number(2)), hw))-    else:-        polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])-    return polys---def _grow_poly(poly, d):-    """Cheap outward growth of a polygon: push every vertex away from the centroid by d."""-    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 = math.hypot(vx, vy) or 1.0-        out.append((x + vx / n * d, y + vy / n * d))-    return out---def _thick_line_poly(a, b, hw):-    dx, dy = b[0] - a[0], b[1] - a[1]-    n = math.hypot(dx, dy) or 1.0-    nx, ny = -dy / n * hw, dx / n * hw-    return [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny), (b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]---def _oriented_extent(polys):-    xs = [p[0] for poly in polys for p in poly]-    ys = [p[1] for poly in polys for p in poly]-    return min(xs), min(ys), max(xs), max(ys)---# ----------------------------------------------------------------------------- board--class Board:-    def __init__(self, path):-        self.path = path-        with open(path, "r", encoding="utf-8", newline="") as f:-            self.text = f.read()-        self.root = parse(self.text)-        if self.root.tag != "kicad_pcb":-            raise ValueError("%s is not a kicad_pcb" % path)-        self.net_table = {}  # number -> name when the file has a (net N "NAME") table-        for n in self.root.find_all("net"):-            try:-                self.net_table[int(n.atom(1))] = n.atom(2) or ""-            except (TypeError, ValueError):-                pass-        self.net_format = "number" if self.net_table else "name"-        self.pads = []-        self.footprints = {}-        self._read_footprints()-        self.outline = self._read_outline()-        xs = [p[0] for p in self.outline]-        ys = [p[1] for p in self.outline]-        self.bbox = (min(xs), min(ys), max(xs), max(ys))-        self.nets = {}-        for p in self.pads:-            if p.net:-                self.nets.setdefault(p.net, []).append(p)-        self.copper_layers = [a.atom(1) for a in self.root.find("layers").items[1:] if a.atom(2) == "signal" or a.atom(2) == "power" or a.atom(2) == "mixed"]--    # -- footprints and pads-    def _read_footprints(self):-        for fp in self.root.find_all("footprint"):-            ref = reference_of(fp)-            at = fp.find("at")-            fx, fy = at.number(1), at.number(2)-            rot = at.number(3) or 0.0-            layer = fp.find("layer").value() if fp.find("layer") is not None else "F.Cu"-            fp_clr = fp.find("clearance")-            fp_clearance = fp_clr.number(1) if fp_clr is not None else 0.0-            self.footprints[ref] = {"x": fx, "y": fy, "rot": rot, "layer": layer, "lib": fp.atom(1), "node": fp}-            for pn in fp.find_all("pad"):-                name = pn.value()-                ptype = pn.atom(2)-                shape = pn.atom(3)-                layers_node = pn.find("layers")-                layers = [a.value for a in layers_node.items[1:]] if layers_node is not None else []-                copper = set()-                for l in layers:-                    if l == "*.Cu":-                        copper.update(("F.Cu", "B.Cu"))-                    elif l.endswith(".Cu"):-                        copper.add(l)-                if not copper or not name:-                    continue  # paste-only apertures and unnamed helper pads carry no copper-                pat = pn.find("at")-                px, py = pat.number(1), pat.number(2)-                pang = pat.number(3) if pat.atom(3) is not None else rot-                size = pn.find("size")-                sx, sy = size.number(1), size.number(2)-                dr = pn.find("drill")-                pad = Pad()-                pad.ref, pad.name, pad.key = ref, name, "%s.%s" % (ref, name)-                nn = pn.find("net")-                pad.net = nn.value() if nn is not None else ""-                if self.net_format == "number" and nn is not None:-                    try:-                        pad.net = self.net_table.get(int(nn.atom(1)), pad.net)-                    except (TypeError, ValueError):-                        pass-                ox, oy = rot_kicad(px, py, rot)-                pad.x, pad.y = fx + ox, fy + oy-                pad.angle = pang-                pad.shape, pad.size = shape, (sx, sy)-                pad.drill = dr.number(1) if dr is not None else None-                pad.thru = ptype == "thru_hole" or ptype == "np_thru_hole"-                pad.layers = frozenset(l for l in copper if l in ("F.Cu", "B.Cu"))-                pad.fp_x, pad.fp_y = fx, fy-                u = pn.find("uuid")-                pad.uuid = u.value() if u is not None else ""-                pclr = pn.find("clearance")-                # KiCad: the effective clearance between two items is the largest of the rule and both local overrides-                pad.clearance = max(fp_clearance, pclr.number(1) if pclr is not None else 0.0)-                local = _pad_local_polys(pn, shape, sx, sy)-                x0, y0, x1, y1 = _oriented_extent(local)-                lx, ly = (x1 - x0) / 2.0, (y1 - y0) / 2.0-                # centre of the copper in the local frame (custom pads are often offset from the origin)-                lcx, lcy = (x0 + x1) / 2.0, (y0 + y1) / 2.0-                if lx >= ly:-                    pad.half_long, pad.half_short = lx, ly-                    axis = rot_kicad(1.0, 0.0, pang)-                else:-                    pad.half_long, pad.half_short = ly, lx-                    axis = rot_kicad(0.0, 1.0, pang)-                pad.long_axis = axis-                for poly in local:-                    pad.polys.append([(pad.x + rot_kicad(x, y, pang)[0], pad.y + rot_kicad(x, y, pang)[1]) for x, y in poly])-                # keep the copper centre for escapes (custom pads)-                ccx, ccy = rot_kicad(lcx, lcy, pang)-                pad.polys_centre = (pad.x + ccx, pad.y + ccy)-                self.pads.append(pad)--    # -- outline-    def _read_outline(self):-        pieces = []-        for node in self.root.items:-            if not hasattr(node, "tag"):-                continue-            layer = node.find("layer") if node.tag and node.tag.startswith("gr_") else None-            if layer is None or layer.value() != "Edge.Cuts":-                continue-            if node.tag == "gr_line":-                s, e = node.find("start"), node.find("end")-                pieces.append([(s.number(1), s.number(2)), (e.number(1), e.number(2))])-            elif node.tag == "gr_arc":-                s, m, e = node.find("start"), node.find("mid"), node.find("end")-                pieces.append(arc_points((s.number(1), s.number(2)), (m.number(1), m.number(2)), (e.number(1), e.number(2))))-            elif node.tag == "gr_rect":-                s, e = node.find("start"), node.find("end")-                x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)-                return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]-            elif node.tag == "gr_circle":-                c, e = node.find("center"), node.find("end")-                r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))-                return circle_poly(c.number(1), c.number(2), r, 64)-            elif node.tag == "gr_poly":-                pts = node.find("pts")-                return [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]-        if not pieces:-            raise ValueError("no Edge.Cuts outline")-        return chain_polylines(pieces)--    def net_ref_sexpr(self, net_name):-        """The `(net ...)` line for copper on this board, matching the bridge's net_sexpr."""-        if self.net_format == "number":-            number = next((n for n, name in self.net_table.items() if name == net_name), None)-            if number is None:-                raise KeyError("net %r is not in the net table" % net_name)-            return "\t\t(net %d)\n" % number-        return "\t\t(net %s)\n" % _quote(net_name)--    def pad_by_key(self, key):-        for p in self.pads:-            if p.key == key:-                return p-        return None---def _quote(s):-    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'---# ----------------------------------------------------------------------------- grid--class Grid:-    """A 0.1 mm cell grid covering the outline bbox. Cell (i, j) is centred on (x0 + i*RES, y0 + j*RES)."""--    def __init__(self, bbox, res=RES):-        self.res = res-        self.x0, self.y0 = bbox[0], bbox[1]-        self.W = int(round((bbox[2] - bbox[0]) / res)) + 1-        self.H = int(round((bbox[3] - bbox[1]) / res)) + 1--    def to_cell(self, x, y):-        # round half UP (not Python's half-to-even) so pads at a 0.5 mm pitch on .x5 coordinates-        # snap to cells a consistent 5 apart-        # 1e-3 cell (0.1 um) of tolerance: KiCad coordinates carry nanometre noise (123.849998)-        return int(math.floor((x - self.x0) / self.res + 0.5 + 1e-3)), int(math.floor((y - self.y0) / self.res + 0.5 + 1e-3))--    def to_xy(self, i, j):-        return round(self.x0 + i * self.res, 4), round(self.y0 + j * self.res, 4)--    def in_bounds(self, i, j):-        return 0 <= i < self.W and 0 <= j < self.H--    # -- rasterisers (all conservative: any touch of the cell square marks the cell)-    def poly_mask(self, poly, mask=None):-        if mask is None:-            mask = np.zeros((self.H, self.W), dtype=bool)-        xs = np.array([p[0] for p in poly])-        ys = np.array([p[1] for p in poly])-        i0 = max(0, int(math.floor((xs.min() - self.x0) / self.res)) - 1)-        i1 = min(self.W - 1, int(math.ceil((xs.max() - self.x0) / self.res)) + 1)-        j0 = max(0, int(math.floor((ys.min() - self.y0) / self.res)) - 1)-        j1 = min(self.H - 1, int(math.ceil((ys.max() - self.y0) / self.res)) + 1)-        if i1 < i0 or j1 < j0:-            return mask-        ci = np.arange(i0, i1 + 1)-        cj = np.arange(j0, j1 + 1)-        cx = self.x0 + ci * self.res-        cy = self.y0 + cj * self.res-        CX, CY = np.meshgrid(cx, cy)-        inside = np.zeros(CX.shape, dtype=bool)-        n = len(poly)-        for k in range(n):-            xa, ya = poly[k]-            xb, yb = poly[(k + 1) % n]-            if ya == yb:-                continue-            cond = (ya > CY) != (yb > CY)-            xint = xa + (CY - ya) * (xb - xa) / (yb - ya)-            inside ^= cond & (CX < xint)-        # boundary supercover: sample each edge finely and mark the containing cells-        for k in range(n):-            xa, ya = poly[k]-            xb, yb = poly[(k + 1) % n]-            L = math.hypot(xb - xa, yb - ya)-            steps = max(1, int(math.ceil(L / 0.025)))-            t = np.linspace(0.0, 1.0, steps + 1)-            ex = xa + (xb - xa) * t-            ey = ya + (yb - ya) * t-            ii = np.round((ex - self.x0) / self.res).astype(int)-            jj = np.round((ey - self.y0) / self.res).astype(int)-            ok = (ii >= i0) & (ii <= i1) & (jj >= j0) & (jj <= j1)-            inside[jj[ok] - j0, ii[ok] - i0] = True-        mask[j0:j1 + 1, i0:i1 + 1] |= inside-        return mask--    def segment_cells(self, a, b, half_width, slack=None):-        """Cells whose centre is within half_width + slack of segment a-b. Returns (jj, ii) index arrays."""-        if slack is None:-            slack = self.res / 2.0-        r = half_width + slack-        xa, ya = a-        xb, yb = b-        i0 = max(0, int(math.floor((min(xa, xb) - r - self.x0) / self.res)))-        i1 = min(self.W - 1, int(math.ceil((max(xa, xb) + r - self.x0) / self.res)))-        j0 = max(0, int(math.floor((min(ya, yb) - r - self.y0) / self.res)))-        j1 = min(self.H - 1, int(math.ceil((max(ya, yb) + r - self.y0) / self.res)))-        if i1 < i0 or j1 < j0:-            return np.array([], dtype=int), np.array([], dtype=int)-        ci = np.arange(i0, i1 + 1)-        cj = np.arange(j0, j1 + 1)-        CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)-        dx, dy = xb - xa, yb - ya-        L2 = dx * dx + dy * dy-        if L2 < 1e-12:-            t = np.zeros_like(CX)-        else:-            t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)-        px = xa + t * dx-        py = ya + t * dy-        d = np.hypot(CX - px, CY - py)-        jj, ii = np.nonzero(d <= r + 1e-9)-        return jj + j0, ii + i0--    def disk_cells(self, c, radius, slack=None):-        return self.segment_cells(c, c, radius, slack)--    def dilate(self, mask, r_cells):-        """Binary dilation by a disk of radius r_cells (float, in cells)."""-        if r_cells <= 0:-            return mask.copy()-        R = int(math.floor(r_cells))-        H, W = mask.shape-        padded = np.zeros((H + 2 * R, W + 2 * R), dtype=bool)-        padded[R:R + H, R:R + W] = mask-        out = np.zeros_like(mask)-        r2 = r_cells * r_cells + 1e-9-        for dy in range(-R, R + 1):-            for dx in range(-R, R + 1):-                if dx * dx + dy * dy <= r2:-                    out |= padded[R + dy:R + dy + H, R + dx:R + dx + W]-        return out--    def outline_inside(self, outline, margin):-        """Cells whose centre is inside the outline polygon and at least `margin` from every edge."""-        inside, dmin = self.outline_distance(outline)-        return inside & (dmin >= margin)--    def outline_distance(self, outline):-        """(inside mask, distance of every cell centre to the nearest outline edge)."""-        inside = np.zeros((self.H, self.W), dtype=bool)-        ci = np.arange(self.W)-        cj = np.arange(self.H)-        CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)-        n = len(outline)-        for k in range(n):-            xa, ya = outline[k]-            xb, yb = outline[(k + 1) % n]-            if ya == yb:-                continue-            cond = (ya > CY) != (yb > CY)-            xint = xa + (CY - ya) * (xb - xa) / (yb - ya)-            inside ^= cond & (CX < xint)-        dmin = np.full(CX.shape, np.inf)-        for k in range(n):-            xa, ya = outline[k]-            xb, yb = outline[(k + 1) % n]-            dx, dy = xb - xa, yb - ya-            L2 = dx * dx + dy * dy-            if L2 < 1e-12:-                continue-            t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)-            d = np.hypot(CX - (xa + t * dx), CY - (ya + t * dy))-            dmin = np.minimum(dmin, d)-        return inside, dmin---def fmt(v):-    return num(v)
reference/python/fixture.pydeleted−688
@@ -1,688 +0,0 @@-#!/usr/bin/env python3-"""Build the placement demo fixture from a routed, placed KiCad 10 board.--    python3 tools/make_placement_fixture.py BOARD.kicad_pcb [--out DIR] [--gap 2] [--margin 5]--Writes two copies beside each other (default: next to the input):--  <name>-unplaced.kicad_pcb   every footprint moved out of the board outline into a parking-                              grid to the right of it (rows sorted by reference, 2 mm gaps,-                              rotation 0, side kept), every segment, arc and via deleted,-                              zones kept with their fills dropped-  <name>-unrouted.kicad_pcb   the original placement, only the copper and the fills stripped--The file is edited in place at the byte level: a small s-expression tokenizer records where-every node starts and ends, and only the nodes that change are rewritten, so everything-else (indentation, number formatting, uuids, the setup block) is byte for byte the input.-Teardrop zones (KiCad's generated copper around pads and vias, `(attr (teardrop ...))`)-belong to the copper that is removed and are dropped too unless --keep-teardrops.--Refuses a board with no Edge.Cuts outline: there is nowhere to park relative to.-Python 3, standard library only. `--self-test` runs the built-in checks.-"""-import argparse-import json-import math-import re-import sys-from pathlib import Path--GAP_MM = 2.0-MARGIN_MM = 5.0-COURTYARD_FALLBACK_MM = 0.25-COPPER_TAGS = ("segment", "via", "arc")-ANGLED_CHILDREN = ("pad", "property", "fp_text")---# ----------------------------------------------------------------------------- s-expressions--class Node:-    """A list node: tag, children (Node or Atom), and its [start, end) span in the text."""-    __slots__ = ("start", "end", "items")--    def __init__(self, start):-        self.start = start-        self.end = None-        self.items = []--    @property-    def tag(self):-        first = self.items[0] if self.items else None-        return first.text if isinstance(first, Atom) else None--    def find(self, tag):-        for c in self.items:-            if isinstance(c, Node) and c.tag == tag:-                return c-        return None--    def find_all(self, tag):-        return [c for c in self.items if isinstance(c, Node) and c.tag == tag]--    def atom(self, i):-        c = self.items[i] if i < len(self.items) else None-        return c.value if isinstance(c, Atom) else None--    def value(self):-        return self.atom(1)--    def number(self, i):-        v = self.atom(i)-        try:-            return float(v)-        except (TypeError, ValueError):-            return None---class Atom:-    __slots__ = ("text", "start", "end")--    def __init__(self, text, start, end):-        self.text, self.start, self.end = text, start, end--    @property-    def value(self):-        """The atom without quotes, escapes resolved (only what KiCad emits)."""-        if self.text.startswith('"') and self.text.endswith('"') and len(self.text) >= 2:-            return self.text[1:-1].replace('\\"', '"').replace("\\\\", "\\")-        return self.text---def parse(text):-    """Parse the first top-level list. Every node keeps its exact span in `text`."""-    n = len(text)-    i = 0-    root = None-    stack = []-    while i < n:-        c = text[i]-        if c == "(":-            node = Node(i)-            if stack:-                stack[-1].items.append(node)-            stack.append(node)-            i += 1-        elif c == ")":-            if not stack:-                raise ValueError("unbalanced ')' at offset %d" % i)-            node = stack.pop()-            node.end = i + 1-            if not stack:-                root = node-                break-            i += 1-        elif c == '"':-            j = i + 1-            while j < n:-                if text[j] == "\\":-                    j += 2-                    continue-                if text[j] == '"':-                    break-                j += 1-            if j >= n:-                raise ValueError("unterminated string at offset %d" % i)-            stack[-1].items.append(Atom(text[i:j + 1], i, j + 1))-            i = j + 1-        elif c.isspace():-            i += 1-        else:-            j = i-            while j < n and not text[j].isspace() and text[j] not in "()":-                j += 1-            if not stack:-                raise ValueError("atom outside any list at offset %d" % i)-            stack[-1].items.append(Atom(text[i:j], i, j))-            i = j-    if root is None:-        raise ValueError("no complete top-level list")-    return root---def num(v):-    """KiCad-style trimmed float: 1.5, not 1.500000; -0 is 0."""-    s = ("%.6f" % v).rstrip("0").rstrip(".")-    if s in ("", "-", "-0"):-        return "0"-    return s---def normalize_deg(d):-    r = math.fmod(d, 360.0)-    if r <= -180.0:-        r += 360.0-    if r > 180.0:-        r -= 360.0-    return 0.0 if r == 0 else r---def ref_key(ref):-    letters = re.match(r"[^0-9]*", ref).group(0)-    rest = ref[len(letters):]-    digits = re.match(r"[0-9]*", rest).group(0)-    return (letters.upper(), int(digits) if digits else 0, rest[len(digits):])---def line_span(text, node):-    """The span to cut for a whole-line node: from the start of its line through its newline."""-    s = node.start-    ls = text.rfind("\n", 0, s) + 1-    whole_line = not text[ls:s].strip()-    if not whole_line:-        ls = s  # something else on the line before it: cut the node only-    e = node.end-    if whole_line:-        if text[e:e + 2] == "\r\n":-            e += 2-        elif text[e:e + 1] == "\n":-            e += 1-    return ls, e---# ----------------------------------------------------------------------------- geometry--def points_of(node, coords=("start", "mid", "end", "center")):-    pts = []-    for tag in coords:-        c = node.find(tag)-        if c is not None:-            x, y = c.number(1), c.number(2)-            if x is not None and y is not None:-                pts.append((x, y))-    p = node.find("pts")-    if p is not None:-        for xy in p.find_all("xy"):-            x, y = xy.number(1), xy.number(2)-            if x is not None and y is not None:-                pts.append((x, y))-    return pts---def shape_extent(node):-    """Vertices that bound one graphic shape (rect corners, circle extremes, arc points)."""-    tag = node.tag or ""-    if tag.endswith("rect"):-        a, b = node.find("start"), node.find("end")-        if a is None or b is None:-            return []-        ax, ay, bx, by = a.number(1), a.number(2), b.number(1), b.number(2)-        return [(ax, ay), (bx, ay), (bx, by), (ax, by)]-    if tag.endswith("circle"):-        c, e = node.find("center"), node.find("end")-        if c is None or e is None:-            return []-        cx, cy = c.number(1), c.number(2)-        r = math.hypot(e.number(1) - cx, e.number(2) - cy)-        return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)]-    return points_of(node)---def bbox(points):-    if not points:-        return None-    xs = [p[0] for p in points]-    ys = [p[1] for p in points]-    return (min(xs), min(ys), max(xs), max(ys))---def layer_of(node):-    l = node.find("layer")-    return l.value() if l is not None else None---def outline_bbox(root):-    pts = []-    for tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly"):-        for node in root.find_all(tag):-            if layer_of(node) == "Edge.Cuts":-                pts.extend(shape_extent(node))-    for fp in root.find_all("footprint"):-        at = fp.find("at")-        fx, fy = (at.number(1), at.number(2)) if at is not None else (0.0, 0.0)-        for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):-            for node in fp.find_all(tag):-                if layer_of(node) == "Edge.Cuts":-                    pts.extend((x + fx, y + fy) for x, y in shape_extent(node))-    return bbox(pts)---def courtyard_local(fp):-    """The footprint's courtyard box in local coordinates at rotation 0, and its source."""-    pts = []-    for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):-        for node in fp.find_all(tag):-            if layer_of(node) in ("F.CrtYd", "B.CrtYd"):-                pts.extend(shape_extent(node))-    b = bbox(pts)-    if b:-        return b, "courtyard"-    corners = []-    for pad in fp.find_all("pad"):-        at, size = pad.find("at"), pad.find("size")-        if at is None:-            continue-        x, y = at.number(1), at.number(2)-        w = (size.number(1) if size is not None else 0.0) / 2.0-        h = (size.number(2) if size is not None else 0.0) / 2.0-        corners.extend([(x - w, y - h), (x + w, y + h)])-    b = bbox(corners)-    if b:-        m = COURTYARD_FALLBACK_MM-        return (b[0] - m, b[1] - m, b[2] + m, b[3] + m), "pads+0.25mm"-    return (-0.5, -0.5, 0.5, 0.5), "position"---def reference_of(fp):-    for p in fp.find_all("property"):-        if p.value() == "Reference":-            return p.atom(2) or ""-    return ""---# ----------------------------------------------------------------------------- the edit--def parking_grid(footprints, outline, gap=GAP_MM, margin=MARGIN_MM):-    """Rows of footprints to the right of the outline, sorted by reference.--    footprints: list of (ref, courtyard_local_bbox). Returns {ref: (x, y)} footprint-    origins and the parking bbox. Row width grows with the total area so the block stays-    roughly square rather than one long strip."""-    cells = sorted(footprints, key=lambda f: ref_key(f[0]))-    area = sum((c[2] - c[0] + gap) * (c[3] - c[1] + gap) for _, c in cells)-    row_width = max(40.0, math.ceil(math.sqrt(area) * 1.15))-    x0 = outline[2] + margin-    y0 = outline[1]-    cursor_x, cursor_y, row_h = x0, y0, 0.0-    origins = {}-    pts = []-    for ref, c in cells:-        w, h = c[2] - c[0], c[3] - c[1]-        if cursor_x > x0 and cursor_x + w > x0 + row_width:-            cursor_x = x0-            cursor_y += row_h + gap-            row_h = 0.0-        origins[ref] = (cursor_x - c[0], cursor_y - c[1])-        pts.extend([(cursor_x, cursor_y), (cursor_x + w, cursor_y + h)])-        cursor_x += w + gap-        row_h = max(row_h, h)-    return origins, bbox(pts)---def at_edit(at_node, x, y, angle, keep_angle_atom):-    """Rewrite one (at ...) node. angle None keeps the node's own angle handling."""-    parts = ["at", num(x), num(y)]-    if angle is not None and (keep_angle_atom or angle != 0.0):-        parts.append(num(angle))-    return (at_node.start, at_node.end, "(" + " ".join(parts) + ")")---def plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones=False, strip_graphics=False, keep_refs=None):-    """All edits for one copy. Returns (edits, summary)."""-    edits = []-    removed = {"segments": 0, "vias": 0, "arcs": 0, "filledPolygons": 0, "teardropZones": 0, "groupsDropped": 0, "groupMembersDropped": 0}-    deleted_ids = set()-    deleted_nodes = set()--    def uuid_of(node):-        u = node.find("uuid")-        return u.value() if u is not None else None--    for node in root.items:-        if not isinstance(node, Node):-            continue-        if node.tag in COPPER_TAGS:-            edits.append((*line_span(text, node), ""))-            removed[{"segment": "segments", "via": "vias", "arc": "arcs"}[node.tag]] += 1-            if uuid_of(node):-                deleted_ids.add(uuid_of(node))-        elif node.tag == "zone" and strip_zones:-            edits.append((*line_span(text, node), ""))-            deleted_nodes.add(id(node))-            removed["zonesStripped"] = removed.get("zonesStripped", 0) + 1-            if uuid_of(node):-                deleted_ids.add(uuid_of(node))-            continue-        elif strip_graphics and node.tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly", "gr_text", "gr_text_box", "dimension", "image", "table") and layer_of(node) != "Edge.Cuts":-            edits.append((*line_span(text, node), ""))-            deleted_nodes.add(id(node))-            removed["graphicsStripped"] = removed.get("graphicsStripped", 0) + 1-            if uuid_of(node):-                deleted_ids.add(uuid_of(node))-            continue-        elif node.tag == "zone" and not keep_teardrops:-            attr = node.find("attr")-            if attr is not None and attr.find("teardrop") is not None:-                edits.append((*line_span(text, node), ""))-                deleted_nodes.add(id(node))-                removed["teardropZones"] += 1-                if uuid_of(node):-                    deleted_ids.add(uuid_of(node))-                continue--    def strip_fills(node):-        for c in node.items:-            if not isinstance(c, Node) or id(c) in deleted_nodes:-                continue-            if c.tag == "filled_polygon":-                edits.append((*line_span(text, c), ""))-                removed["filledPolygons"] += 1-            elif c.tag in ("zone", "footprint"):-                strip_fills(c)-    strip_fills(root)--    outline = outline_bbox(root)-    parked = 0-    parking = None-    fps = root.find_all("footprint")-    import re as _re-    keep_re = _re.compile(keep_refs) if keep_refs else None-    kept_refs = []-    if keep_re:-        for fp in fps:-            ref = reference_of(fp)-            if keep_re.fullmatch(ref or ""):-                kept_refs.append(ref)-                if fp.find("locked") is None:-                    # insert (locked yes) right after the layer line of the footprint-                    layer = fp.find("layer")-                    if layer is not None:-                        st, en = line_span(text, layer)-                        edits.append((en, en, "\t\t(locked yes)\n"))-        fps = [fp for fp in fps if not keep_re.fullmatch(reference_of(fp) or "")]-    if park:-        cells = []-        for fp in fps:-            cells.append((reference_of(fp), courtyard_local(fp)[0]))-        origins, parking = parking_grid(cells, outline, gap, margin)-        for fp in fps:-            ref = reference_of(fp)-            at = fp.find("at")-            if at is None:-                continue-            rot = at.number(3) or 0.0-            x, y = origins[ref]-            edits.append(at_edit(at, x, y, None, False))-            if rot != 0.0:-                # Child angles are stored absolute (footprint angle included): keep each-                # child's angle relative to its footprint when the footprint goes to 0.-                for child in fp.items:-                    if isinstance(child, Node) and child.tag in ANGLED_CHILDREN:-                        cat = child.find("at")-                        if cat is None:-                            continue-                        has_angle = cat.atom(3) is not None-                        old = cat.number(3) or 0.0-                        edits.append(at_edit(cat, cat.number(1), cat.number(2), normalize_deg(old - rot), has_angle))-            parked += 1--    # Groups that referenced removed copper: drop the dead members, drop empty groups.-    for g in root.find_all("group"):-        members = g.find("members")-        if members is None:-            continue-        ids = [a for a in members.items[1:] if isinstance(a, Atom)]-        dead = [a for a in ids if a.value in deleted_ids]-        if not dead:-            continue-        if len(dead) == len(ids):-            edits.append((*line_span(text, g), ""))-            removed["groupsDropped"] += 1-        else:-            keep = [a.text for a in ids if a.value not in deleted_ids]-            edits.append((members.start, members.end, "(members " + " ".join(keep) + ")"))-            removed["groupMembersDropped"] += len(dead)--    summary = {-        "footprints": len(fps), "footprintsParked": parked, "removed": removed,-        "outline": {"minX": outline[0], "minY": outline[1], "maxX": outline[2], "maxY": outline[3]},--        "parking": None if parking is None else {"minX": round(parking[0], 3), "minY": round(parking[1], 3), "maxX": round(parking[2], 3), "maxY": round(parking[3], 3)},-    }-    summary["keptInPlace"] = kept_refs-    return edits, summary---def apply_edits(text, edits):-    edits = sorted(edits, key=lambda e: (e[0], e[1]))-    for i in range(1, len(edits)):-        if edits[i][0] < edits[i - 1][1]:-            raise ValueError("overlapping edits at %d" % edits[i][0])-    out = []-    pos = 0-    for s, e, rep in edits:-        out.append(text[pos:s])-        out.append(rep)-        pos = e-    out.append(text[pos:])-    return "".join(out)---def make(text, park, gap=GAP_MM, margin=MARGIN_MM, keep_teardrops=False, strip_zones=False, strip_graphics=False, keep_refs=None):-    root = parse(text)-    if root.tag != "kicad_pcb":-        raise ValueError("not a kicad_pcb file")-    if outline_bbox(root) is None:-        raise ValueError("no Edge.Cuts outline found; refusing (nothing to park relative to)")-    edits, summary = plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones, strip_graphics, keep_refs)-    return apply_edits(text, edits), summary---def main(argv=None):-    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])-    ap.add_argument("board", nargs="?", help="a .kicad_pcb (KiCad 10 s-expression)")-    ap.add_argument("--out", help="output directory (default: beside the input)")-    ap.add_argument("--gap", type=float, default=GAP_MM, help="gap between parked footprints, mm")-    ap.add_argument("--margin", type=float, default=MARGIN_MM, help="gap between the outline and the parking grid, mm")-    ap.add_argument("--keep-teardrops", action="store_true", help="keep teardrop zones (generated copper) instead of dropping them")-    ap.add_argument("--strip-zones", action="store_true", help="drop every zone (the demo board starts with no copper pours at all)")-    ap.add_argument("--keep-refs", help="regex of references that stay where they are and get locked (a molecule's machine pins and contacts, e.g. 'M[CP]\\d+')")-    ap.add_argument("--strip-graphics", action="store_true", help="drop board-level graphics and text on every layer except Edge.Cuts (labels and logos that describe the original layout)")-    ap.add_argument("--self-test", action="store_true")-    a = ap.parse_args(argv)-    if a.self_test:-        return self_test()-    if not a.board:-        ap.error("board is required")-    src = Path(a.board)-    # newline="" keeps the file's own line endings (KiCad on Windows writes CRLF).-    with open(src, "r", encoding="utf-8", newline="") as f:-        text = f.read()-    out_dir = Path(a.out) if a.out else src.parent-    out_dir.mkdir(parents=True, exist_ok=True)-    stem = src.name[: -len(".kicad_pcb")] if src.name.endswith(".kicad_pcb") else src.stem-    report = {"source": str(src), "outputs": {}}-    for kind, park in (("unplaced", True), ("unrouted", False)):-        new_text, summary = make(text, park, a.gap, a.margin, a.keep_teardrops, a.strip_zones, a.strip_graphics, a.keep_refs)-        dest = out_dir / ("%s-%s.kicad_pcb" % (stem, kind))-        with open(dest, "w", encoding="utf-8", newline="") as f:-            f.write(new_text)-        parse(new_text)  # the output must still be one balanced document-        summary["path"] = str(dest)-        summary["bytes"] = len(new_text.encode("utf-8"))-        report["outputs"][kind] = summary-        r = summary["removed"]-        line = "%s: %d footprints" % (dest.name, summary["footprints"])-        if park:-            p = summary["parking"]-            line += ", %d parked in x %s..%s y %s..%s" % (summary["footprintsParked"], num(p["minX"]), num(p["maxX"]), num(p["minY"]), num(p["maxY"]))-        line += "; removed %d segments, %d vias, %d arcs, %d filled polygons, %d teardrop zones" % (-            r["segments"], r["vias"], r["arcs"], r["filledPolygons"], r["teardropZones"])-        if r.get("zonesStripped") or r.get("graphicsStripped"):-            line += ", %d zones stripped, %d graphics stripped" % (r.get("zonesStripped", 0), r.get("graphicsStripped", 0))-        if r["groupsDropped"] or r["groupMembersDropped"]:-            line += ", %d groups dropped, %d group members dropped" % (r["groupsDropped"], r["groupMembersDropped"])-        print(line)-    o = report["outputs"]["unplaced"]["outline"]-    print("outline bbox: x %s..%s y %s..%s (%s x %s mm); source %d bytes" % (-        num(o["minX"]), num(o["maxX"]), num(o["minY"]), num(o["maxY"]), num(o["maxX"] - o["minX"]), num(o["maxY"] - o["minY"]), len(text.encode("utf-8"))))-    (out_dir / ("%s-placement-fixture.json" % stem)).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")-    return 0---# ----------------------------------------------------------------------------- self-test--SAMPLE = """(kicad_pcb-\t(version 20260206)-\t(generator "pcbnew")-\t(layers-\t\t(0 "F.Cu" signal)-\t\t(2 "B.Cu" signal)-\t)-\t(footprint "T:R2"-\t\t(layer "F.Cu")-\t\t(uuid "r2")-\t\t(at 120 110 90)-\t\t(property "Reference" "R2"-\t\t\t(at 0 -1.17 90)-\t\t\t(layer "F.SilkS")-\t\t)-\t\t(fp_rect-\t\t\t(start -1 -0.5)-\t\t\t(end 1 0.5)-\t\t\t(layer "F.CrtYd")-\t\t)-\t\t(pad "1" smd rect-\t\t\t(at -0.5 0 90)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "F.Cu")-\t\t\t(net "A")-\t\t)-\t\t(pad "2" smd rect-\t\t\t(at 0.5 0)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "F.Cu")-\t\t\t(net "B")-\t\t)-\t)-\t(footprint "T:R10"-\t\t(layer "B.Cu")-\t\t(uuid "r10")-\t\t(at 130 110)-\t\t(property "Reference" "R10"-\t\t\t(at 0 -1.17 0)-\t\t)-\t\t(pad "1" smd rect-\t\t\t(at -0.5 0)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "B.Cu")-\t\t\t(net "A")-\t\t)-\t)-\t(gr_rect-\t\t(start 100 100)-\t\t(end 150 130)-\t\t(layer "Edge.Cuts")-\t\t(uuid "edge")-\t)-\t(segment-\t\t(start 119.5 110)-\t\t(end 129.5 110)-\t\t(width 0.2)-\t\t(layer "F.Cu")-\t\t(net "A")-\t\t(uuid "seg1")-\t)-\t(via-\t\t(at 125 110)-\t\t(size 0.6)-\t\t(drill 0.3)-\t\t(layers "F.Cu" "B.Cu")-\t\t(net "A")-\t\t(uuid "via1")-\t)-\t(zone-\t\t(net "GND")-\t\t(layer "F.Cu")-\t\t(uuid "z1")-\t\t(polygon-\t\t\t(pts-\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130) (xy 100 130)-\t\t\t)-\t\t)-\t\t(filled_polygon-\t\t\t(layer "F.Cu")-\t\t\t(pts-\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130)-\t\t\t)-\t\t)-\t)-\t(zone-\t\t(net "A")-\t\t(layer "F.Cu")-\t\t(uuid "td1")-\t\t(attr-\t\t\t(teardrop-\t\t\t\t(type padvia)-\t\t\t)-\t\t)-\t\t(filled_polygon-\t\t\t(layer "F.Cu")-\t\t\t(pts-\t\t\t\t(xy 1 1) (xy 2 2) (xy 3 3)-\t\t\t)-\t\t)-\t)-\t(group ""-\t\t(uuid "g1")-\t\t(members "seg1" "edge")-\t)-\t(group ""-\t\t(uuid "g2")-\t\t(members "seg1" "via1")-\t)-\t(embedded_fonts no)-)-"""---def self_test():-    unplaced, s = make(SAMPLE, True)-    root = parse(unplaced)-    assert s["removed"] == {"segments": 1, "vias": 1, "arcs": 0, "filledPolygons": 1, "teardropZones": 1, "groupsDropped": 1, "groupMembersDropped": 1}, s-    assert s["footprintsParked"] == 2-    assert s["outline"] == {"minX": 100.0, "minY": 100.0, "maxX": 150.0, "maxY": 130.0}-    assert not root.find_all("segment") and not root.find_all("via")-    zones = root.find_all("zone")-    assert len(zones) == 1 and zones[0].find("uuid").value() == "z1", "user zone kept, teardrop dropped"-    assert not zones[0].find("filled_polygon") and zones[0].find("polygon") is not None-    groups = root.find_all("group")-    assert len(groups) == 1 and [a.value for a in groups[0].find("members").items[1:]] == ["edge"]-    fps = {reference_of(fp): fp for fp in root.find_all("footprint")}-    # R2 sorts before R10; both start at outline.maxX + 5 on the outline's top row.-    r2, r10 = fps["R2"], fps["R10"]-    assert r2.find("at").atom(3) is None, "rotation zeroed"-    assert r2.find("at").number(1) == 156.0 and r2.find("at").number(2) == 100.5, unplaced-    assert r10.find("at").number(1) > r2.find("at").number(1) + 2.0-    assert layer_of(r10) == "B.Cu", "side kept"-    # Child angles stay relative: the 90 absolute became 0, the missing angle became -90.-    pads = r2.find_all("pad")-    assert pads[0].find("at").atom(3) == "0", pads[0].find("at").atom(3)-    assert pads[1].find("at").atom(3) == "-90", pads[1].find("at").atom(3)-    assert r2.find_all("property")[0].find("at").atom(3) == "0"-    assert r10.find_all("pad")[0].find("at").atom(3) is None, "untouched at rotation 0"-    # Everything else is byte for byte the input.-    assert "(generator \"pcbnew\")" in unplaced and "(embedded_fonts no)" in unplaced-    assert unplaced.count("\t(footprint") == 2 and unplaced.endswith(")\n")-    unrouted, s2 = make(SAMPLE, False)-    assert s2["footprintsParked"] == 0 and s2["parking"] is None-    assert "(at 120 110 90)" in unrouted and "(at 130 110)" in unrouted-    assert "(segment" not in unrouted and "(via" not in unrouted and "filled_polygon" not in unrouted-    kept, s3 = make(SAMPLE, False, keep_teardrops=True)-    assert s3["removed"]["teardropZones"] == 0 and kept.count("(zone") == 2-    try:-        make("(kicad_pcb (version 20260206) (footprint \"X\" (at 1 1)))", True)-        raise AssertionError("no outline must refuse")-    except ValueError as e:-        assert "Edge.Cuts" in str(e)-    crlf, s4 = make(SAMPLE.replace("\n", "\r\n"), True)-    assert "\n" not in crlf.replace("\r\n", "") and s4["removed"]["segments"] == 1, "CRLF files keep CRLF and lose no line"-    assert crlf.count("\r\n") == unplaced.count("\n"), (crlf.count("\r\n"), unplaced.count("\n"))-    assert num(1.5) == "1.5" and num(100.0) == "100" and num(-0.0) == "0" and num(0.1234567) == "0.123457"-    assert normalize_deg(270) == -90 and normalize_deg(-270) == 90 and normalize_deg(180) == 180 and normalize_deg(360) == 0-    assert ref_key("C2") < ref_key("C10") < ref_key("R1")-    print("self-test ok")-    return 0---if __name__ == "__main__":-    sys.exit(main())
reference/python/land_moves.pydeleted−46
@@ -1,46 +0,0 @@-#!/usr/bin/env python3-"""Land a placement adjustment on the live board: one kicad_move_footprint batch, one native undo step.--Usage: adjust_live.py --target ConfRoomROG --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" --moves moves.json-                      [--thread kicad-bridge-esc-routing] [--log adjust-log.json] [--dry-run]--moves.json is {REF: {"x", "y", "rotation"}} (what tools/pack_moves.py writes and tools/move_footprints.py-takes), so the offline fixture and the live board end up identical. The bridge refuses courtyard-overlaps and positions outside the outline and verifies every pose by read-back.-"""-import argparse, json, subprocess, sys, time--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--moves", required=True)-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="adjust-log.json")-ap.add_argument("--dry-run", action="store_true")-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]--def call(verb, args, timeout=240):-    t0 = time.time()-    out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout-    try:-        r = json.loads(out)-    except Exception:-        r = {"status": "parse_error", "raw": out[:400]}-    r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))-    return r--moves = json.load(open(a.moves))-st = call("kicad_placement_state", {"filePath": a.board})-if st.get("status") != "ok":-    print("placement_state failed", json.dumps(st)[:600]); sys.exit(1)-rev = st["revision"]-refs = [{"ref": r, "x": m["x"], "y": m["y"], "rotation": m.get("rotation", 0)} for r, m in moves.items()]-args = {"filePath": a.board, "expectedRevision": rev, "refs": refs}-if a.dry_run:-    args["dryRun"] = True-r = call("kicad_move_footprint", args)-print("move_footprint:", r.get("status"), r.get("errorCode") or "", "revision", str(r.get("revision", ""))[:8], r["_dt"], "s")-if r.get("status") != "ok":-    print(json.dumps(r)[:1500])-v = call("kicad_placement_validate", {"filePath": a.board}) if not a.dry_run else {}-print("validate:", {k: v.get(k) for k in ("status", "overlaps", "outside", "ratsnestMm", "errors")})-json.dump({"start": {k: x for k, x in st.items() if k != "footprints"}, "moves": moves, "reply": r, "validate": v}, open(a.log, "w"), indent=1)-sys.exit(0 if r.get("status") == "ok" else 2)
reference/python/land_pours.pydeleted−81
@@ -1,81 +0,0 @@-#!/usr/bin/env python3-"""Land a pours plan on the live board through kicad_add_zone, one undo step per group, then measure.--Usage: pours_live.py --target ConfRoomROG --board "C:/.../board.kicad_pcb" --pours pours.json-                     [--thread kicad-bridge-esc-routing] [--log pours-log.json] [--dry-run] [--pause 1.0]--The JSON is the tools/add_pours.py shape ({"zones": [...]}); "outline": true zones get the board-outline (inset by "inset" mm) resolved here from the local copy of the board given with --local,-because the verb takes explicit polygons. Zones are grouped by net into one kicad_add_zone call-each (one native undo step per net), lowest priority first. kicad_zone_state {refill:true} is read-before and after, so the log carries the copper kept per layer, the number this round is about.-"""-import argparse, json, subprocess, sys, time, os--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--pours", required=True)-ap.add_argument("--local", help="local copy of the board, for outline: true zones")-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="pours-log.json")-ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--pause", type=float, default=1.0)-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]---def call(verb, args, timeout=300):-    t0 = time.time()-    out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout-    try:-        r = json.loads(out)-    except Exception:-        r = {"status": "parse_error", "raw": out[:400]}-    r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))-    return r---spec = json.load(open(a.pours, encoding="utf-8"))-zones = spec["zones"] if isinstance(spec, dict) else spec-outline = None-if any(z.get("outline") for z in zones):-    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-    -    from escboard import Board, polygon_area-    from pours import inset_polygon-    b = Board(a.local)-    outline = list(b.outline)-    if polygon_area(outline) < 0:-        outline.reverse()-groups = {}-for z in zones:-    zz = {k: v for k, v in z.items() if k not in ("outline", "inset")}-    if z.get("outline"):-        zz["polygon"] = [[round(x, 3), round(y, 3)] for x, y in inset_polygon(outline, float(z.get("inset", 0.5)))]-    groups.setdefault(z["net"], []).append(zz)-order = sorted(groups, key=lambda n: min(z.get("priority", 0) for z in groups[n]))--before = call("kicad_zone_state", {"filePath": a.board, "refill": True})-print("before:", before.get("filledAreaByLayerMm2"), before.get("filledCoverageByLayerPercent"))-st = call("kicad_routing_state", {"filePath": a.board})-rev = st.get("revision")-if not rev:-    print("routing_state failed", json.dumps(st)[:600]); sys.exit(1)-log = {"before": {k: before.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount")}, "calls": []}-t_start = time.time()-for net in order:-    args = {"filePath": a.board, "expectedRevision": rev, "zones": groups[net]}-    if a.dry_run:-        args["dryRun"] = True-    r = call("kicad_add_zone", args)-    ok = r.get("success") is True-    print("%-10s %d zone(s): %s %s ids %s refilled %s %.1fs %s" % (net, len(groups[net]), "ok" if ok else "FAILED", r.get("errorCode") or "", len(r.get("itemIds") or []), r.get("refilled"), r["_dt"], (r.get("postCommitError") or "")[:80]))-    log["calls"].append({"net": net, "zones": groups[net], "reply": {k: v for k, v in r.items() if k not in ("_hint",)}})-    if not ok:-        print(json.dumps(r)[:1500]); break-    rev = r.get("revision") or rev-    time.sleep(a.pause)-after = call("kicad_zone_state", {"filePath": a.board, "refill": True})-print("after:", after.get("filledAreaByLayerMm2"), after.get("filledCoverageByLayerPercent"))-v = call("kicad_routing_validate", {"filePath": a.board}) if not a.dry_run else {}-print("validate:", {k: v.get(k) for k in ("errors", "warnings", "unconnected", "clean", "zonesRefilled")})-log["after"] = {k: after.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount", "zones")}-log["validate"] = v; log["wall_seconds"] = round(time.time() - t_start, 1)-json.dump(log, open(a.log, "w"), indent=1)
reference/python/land_route.pydeleted−95
@@ -1,95 +0,0 @@-#!/usr/bin/env python3-"""Replay a routing plan on the live board through the bridge, one kicad_route_net per entry.--Usage: route_live.py --target arav-rog --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" \\-                     --plan fable-esc-routing-plan.json [--pause 0.6] [--thread "kicad-bridge-esc-routing"] \\-                     [--log route-log.json] [--dry-run] [--start N] [--save]--Mirrors demo/placement/fable/place_live.py: kicad_routing_state gives the revision, every-plan entry becomes one kicad_route_net call (one native Undo step, DRC-checked by the bridge-against a snapshot) with expectedRevision from the previous reply, and the reply's revision-feeds the next call. Every reply is logged with timestamps. A drc_rejected or stale_board-reply stops the run and is printed in full; kicad_routing_validate closes the run and the-whole log is written as JSON. --dry-run sends every call with dryRun:true (nothing lands, the-revision never changes). --start N skips the first N entries (resume after a stop).-"""-import argparse-import json-import subprocess-import sys-import time--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--plan", required=True)-ap.add_argument("--pause", type=float, default=0.6)-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="route-log.json")-ap.add_argument("--dry-run", action="store_true")-ap.add_argument("--start", type=int, default=0, help="skip the first N plan entries")-ap.add_argument("--save", action="store_true", help="ask the bridge to save the board after the last entry")-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]---def call(verb, args, timeout=240):-    t0 = time.time()-    out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout-    try:-        r = json.loads(out)-    except Exception:-        r = {"status": "parse_error", "raw": out[:400]}-    r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))-    return r---plan = json.load(open(a.plan, encoding="utf-8"))-entries = plan["nets"] if isinstance(plan, dict) else plan-entries = entries[a.start:]--st = call("kicad_routing_state", {"filePath": a.board})-if st.get("success") is False or st.get("status") == "error" or not st.get("revision"):-    print("routing_state failed", json.dumps(st)[:800]); sys.exit(1)-rev = st["revision"]-print(f"start: revision {rev[:8]}, {len(st.get('pads', []))} pads, {len(st.get('segments', []))} segments, {len(st.get('vias', []))} vias on the board, "-      f"{len(st.get('netsRemaining', []))} nets remaining; {len(entries)} plan entries{' (dry run)' if a.dry_run else ''}")-log = {"start": {k: v for k, v in st.items() if k not in ("pads",)}, "plan": a.plan, "board": a.board, "target": a.target,-       "dryRun": a.dry_run, "entries": []}-t_start = time.time()-ok_count = 0-for i, e in enumerate(entries, 1):-    args = {"filePath": a.board, "expectedRevision": rev, "net": e["net"], "width": e["width"]}-    for k in ("viaSize", "viaDrill", "layer"):-        if k in e:-            args[k] = e[k]-    if "paths" in e:-        args["paths"] = e["paths"]-    else:-        args["points"] = e["points"]-    if a.dry_run:-        args["dryRun"] = True-    if a.save and i == len(entries) and not a.dry_run:-        args["save"] = True-    r = call("kicad_route_net", args)-    ok = r.get("success", True) is not False and r.get("status") != "error" and r.get("status") != "parse_error"-    code = r.get("errorCode") or ""-    drc = r.get("drc") or {}-    n_items = len(r.get("itemIds") or []) if not a.dry_run else len(r.get("segments") or []) + len(r.get("vias") or [])-    paths = len(e.get("paths") or [1])-    print(f"{i}/{len(entries)} {e['net']:<26} w{e['width']:<5} {paths} path(s): {'ok' if ok else 'FAIL'} {code} "-          f"items {n_items} drc errors {drc.get('errors')} unconnected {drc.get('unconnected')} rev {str(r.get('revision', ''))[:8]} {r['_dt']}s")-    log["entries"].append({"index": i + a.start, "net": e["net"], "width": e["width"], "reply": {k: v for k, v in r.items() if k not in ("_hint", "_timeoutHint")}})-    if not ok:-        print(json.dumps(r)[:2000])-        if code in ("drc_rejected", "stale_board") or not a.dry_run:-            break-    else:-        ok_count += 1-    if not a.dry_run:-        rev = r.get("revision") or rev-    time.sleep(a.pause)-v = call("kicad_routing_validate", {"filePath": a.board}, timeout=600)-log["validate"] = {k: val for k, val in v.items() if k not in ("_hint",)}-log["wall_seconds"] = round(time.time() - t_start, 1)-log["ok_entries"] = ok_count-print("validate:", {k: v.get(k) for k in ("status", "success", "errors", "warnings", "unconnected", "clean", "stale", "zonesRefilled")}, "wall", log["wall_seconds"], "s")-json.dump(log, open(a.log, "w"), indent=1)-print("log:", a.log)
reference/python/movefp.pydeleted−115
@@ -1,115 +0,0 @@-#!/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.dirname(__import__("os").path.abspath(__file__)))-        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())
reference/python/pack.pydeleted−136
@@ -1,136 +0,0 @@-#!/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-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()
reference/python/planes.pydeleted−121
@@ -1,121 +0,0 @@-#!/usr/bin/env python3-"""Add full-outline copper planes to a copy of a KiCad 10 board.--    python3 tools/add_planes.py BOARD.kicad_pcb [--out FILE] [--plane GND:In1.Cu] [--plane +3V3:In2.Cu]-                                [--clearance 0.2] [--min-thickness 0.25] [--drc]--Default planes: GND on In1.Cu and +3V3 on In2.Cu. Each plane is one zone whose polygon is the-Edge.Cuts outline (lines as they are, arcs sampled to short chords), thermal-relief pad-connection with the given clearance, priority 0 (KiCad's default, so the line is not written),-fill enabled, `(filled_areas_thickness no)` and NO filled_polygon: KiCad fills it on load or-when a DRC refills zones. The net is written the way this board writes nets: KiCad 10 boards-carry `(net "NAME")` on every pad and no net table, so the zone gets `(net "NAME")`; a board-with a `(net N "NAME")` table gets `(net N)` plus `(net_name "NAME")`.--The zones are spliced in before the closing paren; every other byte of the file is untouched.---drc runs `service-kicad pcb drc` (the shared headless KiCad, which refills zones) on the-output and prints the error, warning and unconnected counts.-"""-import argparse-import json-import os-import subprocess-import sys-import tempfile-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---def zone_sexpr(board, net_name, layer, outline, clearance, min_thickness):-    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)-    pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in outline)-    return (-        "\t(zone\n"-        + net_lines-        + "\t\t(layer \"%s\")\n" % layer-        + "\t\t(uuid \"%s\")\n" % uuid.uuid4()-        + "\t\t(name \"%s plane\")\n" % net_name.replace('"', '\\"')-        + "\t\t(hatch edge 0.5)\n"-        + "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n" % num(clearance)-        + "\t\t(min_thickness %s)\n" % num(min_thickness)-        + "\t\t(filled_areas_thickness no)\n"-        + "\t\t(fill yes\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 splice(text, blocks):-    """Insert blocks before the closing paren of the kicad_pcb list, like the bridge's pcb::splice."""-    trimmed = text.rstrip()-    idx = trimmed.rfind(")")-    body = text[:idx].rstrip()-    nl = "\r\n" if "\r\n" in text[:2000] else "\n"-    out = body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl-    return out---def run_drc(path):-    """service-kicad pcb drc as JSON: (errors, warnings, unconnected, report dict)."""-    with tempfile.TemporaryDirectory() as tmp:-        report = Path(tmp) / "drc.json"-        proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],-                              capture_output=True, text=True, timeout=600)-        if proc.returncode or not report.is_file():-            raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-400:], proc.stderr[-400:]))-        data = json.loads(report.read_text(encoding="utf-8"))-    viol = data.get("violations", [])-    errors = sum(1 for v in viol if v.get("severity") == "error")-    warnings = sum(1 for v in viol if v.get("severity") == "warning")-    return errors, warnings, len(data.get("unconnected_items", [])), data---def main(argv=None):-    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])-    ap.add_argument("board")-    ap.add_argument("--out", help="output path (default: <board>-planes.kicad_pcb beside the input)")-    ap.add_argument("--plane", action="append", help="NET:LAYER, repeatable (default GND:In1.Cu and +3V3:In2.Cu)")-    ap.add_argument("--clearance", type=float, default=0.2)-    ap.add_argument("--min-thickness", type=float, default=0.25)-    ap.add_argument("--drc", action="store_true", help="run service-kicad pcb drc on the output and print the counts")-    a = ap.parse_args(argv)-    planes = [p.split(":", 1) for p in (a.plane or ["GND:In1.Cu", "+3V3:In2.Cu"])]-    src = Path(a.board)-    board = Board(str(src))-    for net, layer in planes:-        if net not in board.nets:-            raise SystemExit("net %r has no pads on this board" % net)-        if layer not in board.copper_layers:-            raise SystemExit("layer %r is not a copper layer of this board (%s)" % (layer, board.copper_layers))-    outline = list(board.outline)-    if polygon_area(outline) < 0:-        outline.reverse()-    blocks = [zone_sexpr(board, net, layer, outline, a.clearance, a.min_thickness) for net, layer in planes]-    out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-planes.kicad_pcb")-    text = splice(board.text, blocks)-    out.write_text(text, encoding="utf-8", newline="")-    Board(str(out))  # must still parse as one balanced document-    print("%s: %d zones (%s), %d outline points, net format %s, %d bytes" % (-        out, len(planes), ", ".join("%s on %s" % (n, l) for n, l in planes), len(outline), board.net_format, len(text.encode("utf-8"))))-    if a.drc:-        errors, warnings, unconnected, data = run_drc(out)-        by_type = {}-        for v in data.get("violations", []):-            by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1-        print("drc: %d errors, %d warnings, %d unconnected items; by type %s (KiCad %s)" % (-            errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version")))-    return 0---if __name__ == "__main__":-    sys.exit(main())
reference/python/pourplan.pydeleted−157
@@ -1,157 +0,0 @@-"""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()
reference/python/pours.pydeleted−167
@@ -1,167 +0,0 @@-#!/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())
reference/python/pours_esc_example.pydeleted−182
@@ -1,182 +0,0 @@-#!/usr/bin/env python3-"""Claude Fable 5.1's copper-pour plan for the ESC G431, from the board's own pad geometry.--    python3 demo/routing/esc/pours_plan.py BOARD.kicad_pcb --out pours.json [--vias vias-plan.json]--Reads the placed board, finds the power stage (the six MOSFET tabs and source pads, the three-phase contacts, the +VBAT contact, the bulk capacitors, the shunt and the GND_OUT contact) and-writes the pours as the JSON kicad_add_zone takes (tools/add_pours.py gates it offline):--  * GND on F.Cu and B.Cu, whole outline, priority 0, thermal reliefs: the copper that stays-    everywhere nothing else needs it (ablation), stitched to the In1.Cu plane by every through-    pad and the existing stub vias.-  * +VBAT, priority 2: the rail block (contact MC1, TVS, the bulk and ceramic capacitors, the-    high-side tab of phase C) on F.Cu and B.Cu, plus a strip on B.Cu behind the MOSFET column-    that reaches every high-side tab through thermal vias in the tabs. The gate driver and its-    bootstrap nodes are left out of the rail pour on purpose (switching nodes beside gate drive).-  * One pour per phase (/DRV_SHA, /DRV_SHB, /DRV_SHC), priority 3, solid connections: on F.Cu-    from the high-side source pads over the low-side tab to the phase contact; on B.Cu only-    under the low-side tab, so the +VBAT strip on B.Cu stays continuous. Compact by design:-    these are the switching nodes.-  * GND_OUT (the battery return through the shunt), priority 3, solid: from the return contact-    MC3 to the shunt's GND_OUT pad, on both outer layers. The two Kelvin traces to the sense-    amplifier stay outside every pour.--Not poured, deliberately: the bootstrap and gate-drive nets, the crystal and its capacitors, the-BEMF dividers and comparator inputs, /VBAT_SENSE and /IBAT_SENSE, +3V3 and /VDDA on the outer-layers (they have the In2.Cu plane), and anything under the MCU's escape bands.----vias also writes the thermal/stitching vias as route_net entries (write_copper.py and-route_live.py replay them): two 0.8/0.4 vias in each high-side tab (F.Cu tab to the B.Cu +VBAT-strip) and a row along the B.Cu strip.-"""-import argparse, json, math, os, sys--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board  # noqa: E402--PHASES = {"/DRV_SHA": "MC5", "/DRV_SHB": "MC9", "/DRV_SHC": "MC11"}---def 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 main():-    ap = argparse.ArgumentParser()-    ap.add_argument("board"); ap.add_argument("--out", required=True); ap.add_argument("--vias")-    ap.add_argument("--avoid", help="routing plan JSON: a via candidate that would hit another net's copper is moved to the next spot in the tab; its Kelvin entries get pour keepouts")-    ap.add_argument("--solid-at", action="append", default=[], help="REF.PAD that gets a small solid-connection patch (starved thermal relief)")-    a = ap.parse_args()-    b = Board(a.board)-    pads = b.pads-    x0, y0, x1, y1 = b.bbox-    edge = 0.5-    # the MOSFET column: high-side tab (+VBAT) and low-side tab (phase) per phase-    q = {}-    for p in pads:-        if p.ref.startswith("Q") and p.name == "5":-            q.setdefault(p.net, []).append(p)-    hs_tabs = q.get("+VBAT", [])                      # three tabs, one per phase-    zones = []-    vias = []-    # 1. GND everywhere, both outer layers, lowest priority-    zones.append({"net": "GND", "layers": ["F.Cu", "B.Cu"], "outline": True, "inset": edge, "priority": 0, "connection": "thermal",-                  "clearance": 0.2, "minThickness": 0.25, "thermalGap": 0.3, "thermalBridge": 0.35, "name": "GND outer pour (ablation: keep the copper)"})-    # pads where only one thermal spoke fits get a small solid patch (KiCad's rule wants two spokes or a solid joint)-    for key in a.solid_at:-        ref, name = key.split(".")-        pd = next(p for p in pads if p.ref == ref and p.name == name)-        zones.append({"net": pd.net, "layers": ["F.Cu"], "polygon": rect(pd.x - 0.9, pd.y - 0.9, pd.x + 0.9, pd.y + 0.9), "priority": 1, "connection": "solid",-                      "clearance": 0.2, "minThickness": 0.25, "name": "%s solid joint at %s (one spoke only)" % (pd.net, key)})-    # 2. the rail: contact MC1, TVS D1, bulk caps C2/C3, the ceramic bank, down to the phase-C high-side tab-    rail_parts = [p for p in pads if p.net == "+VBAT" and (p.ref.startswith("C") or p.ref in ("D1", "MC1"))]-    rb = box(rail_parts)-    col = box([p for p in pads if p.ref.startswith("Q")])-    driver = box([p for p in pads if p.ref == "U4"])-    rail_left = 127.5                                   # right of the buck (U2) and its SW node; the TVS is inside-    rail_bottom = driver[1] - 1.0                       # above the gate driver's band-    strip_right = x1 - edge - 1.6-    zones.append({"net": "+VBAT", "layers": ["F.Cu"], "polygon": rect(rail_left, y0 + edge, strip_right, rail_bottom), "priority": 2, "connection": "solid",-                  "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block F.Cu"})-    # B.Cu: the rail block plus the strip behind the MOSFET column as ONE L-shaped zone (two zones of one-    # net and one priority may not touch). The strip reaches every high-side tab through the thermal vias;-    # the phase pours under the low-side tabs (priority 3) punch through it and the +VBAT copper flows around.-    L = [[rail_left, y0 + edge], [strip_right, y0 + edge], [strip_right, col[3] + 1.0], [col[0] - 0.8, col[3] + 1.0], [col[0] - 0.8, rail_bottom], [rail_left, rail_bottom]]-    zones.append({"net": "+VBAT", "layers": ["B.Cu"], "polygon": [[round(x, 2), round(y, 2)] for x, y in L], "priority": 2, "connection": "solid",-                  "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block and column strip B.Cu"})-    # 3. one compact pour per phase-    for net, contact in PHASES.items():-        hs_src = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name in ("1", "2", "3")]-        ls_tab = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name == "5"]-        mc = [p for p in pads if p.ref == contact]-        fb = box(hs_src + ls_tab + mc)-        zones.append({"net": net, "layers": ["F.Cu"], "polygon": rect(fb[0] - 0.5, fb[1] - 0.6, x1 - edge, fb[3] + 0.6), "priority": 3, "connection": "solid",-                      "clearance": 0.2, "minThickness": 0.25, "name": "%s phase F.Cu" % net})-        lb = box(ls_tab)-        zones.append({"net": net, "layers": ["B.Cu"], "polygon": rect(lb[0] - 1.5, lb[1] - 0.3, lb[2] + 0.3, lb[3] + 0.6), "priority": 3, "connection": "solid",-                      "clearance": 0.2, "minThickness": 0.25, "name": "%s under the low-side tab B.Cu" % net})-    # 4. the return through the shunt: contact MC3 to the shunt's GND_OUT pad-    mc3 = [p for p in pads if p.ref == "MC3"]-    r32 = [p for p in pads if p.ref == "R32" and p.net == "GND_OUT"]-    gb = box(mc3 + r32)-    zones.append({"net": "GND_OUT", "layers": ["F.Cu", "B.Cu"], "polygon": rect(gb[0] - 0.4, max(y0 + edge, gb[1] - 0.6), gb[2] + 0.4, gb[3] + 0.6), "priority": 3, "connection": "solid",-                  "clearance": 0.2, "minThickness": 0.25, "name": "GND_OUT return"})-    # thermal vias in every high-side tab (two per tab, moved within the tab when the routed copper of-    # another net passes under that spot on B.Cu), and two stitching vias along the B.Cu strip-    blockers = []-    if a.avoid:-        for e in json.load(open(a.avoid))["nets"]:-            if e["net"] == "+VBAT":-                continue-            for path in e["paths"]:-                prev = None; layer = "F.Cu"-                for w in path:-                    if isinstance(w, str):-                        prev = None; continue-                    if isinstance(w, dict):-                        if "x" not in w:-                            continue-                        layer = w.get("layer", layer); pt = (w["x"], w["y"])-                    else:-                        pt = (w[0], w[1])-                    if prev is not None:-                        blockers.append((prev, pt, e["width"] / 2.0 + 0.2 + 0.4))-                    prev = pt--    def clear(x, y):-        for (ax, ay), (bx, by), need in blockers:-            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 t in hs_tabs:-        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)):-            if placed >= 2:-                break-            x, y = round(t.x + dx, 2), round(t.y + dy, 2)-            if clear(x, y):-                vias.append({"net": "+VBAT", "x": x, "y": y}); placed += 1-    strip_x = round(col[2] + 1.9, 2)-    for yy in (90.0, 118.0):-        if clear(strip_x, yy):-            vias.append({"net": "+VBAT", "x": strip_x, "y": yy})-    # pour keepouts around every Kelvin tap in the routing plan (both outer layers, the whole trace, not the pads)-    if a.avoid:-        for e in json.load(open(a.avoid))["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 = 0.45   # half width of the keepout: trace half width 0.15 + clearance 0.2 + margin 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"]})-    json.dump({"zones": zones}, open(a.out, "w"), indent=1)-    print("%d zones -> %s" % (len(zones), a.out))-    for z in zones:-        print("  %-10s %-12s %s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %d %s" % (z["priority"], z["connection"]), z.get("polygon") or "outline"))-    if a.vias:-        entries = [{"net": v["net"], "width": 0.5, "viaSize": 0.8, "viaDrill": 0.4, "paths": [[[v["x"], v["y"]], {"x": v["x"], "y": v["y"], "layer": "B.Cu"}]]} for v in vias]-        json.dump({"engine": "Claude Fable 5.1 pours plan (thermal and stitching vias)", "nets": entries}, open(a.vias, "w"), indent=1)-        print("%d vias -> %s" % (len(entries), a.vias))---if __name__ == "__main__":-    main()
reference/python/router.pydeleted−1420
@@ -1,1420 +0,0 @@-#!/usr/bin/env python3-"""adom-boardflow's routing engine: a grid maze router for any KiCad 10 board (born on the ESC G431).--    python3 demo/routing/esc/ai_router.py BOARD-planes.kicad_pcb [--out plan.json] [--report report.json]-                                          [--nets NET,NET] [--no-planes] [--seed 0] [--verbose]--What it does-  * 0.1 mm grid over the Edge.Cuts outline, two signal layers (F.Cu, B.Cu), through vias between-    them (0.6 mm / 0.3 mm drill for signals, 0.8 mm / 0.4 mm on the wide power nets), KiCad's default rules for this board: 0.25 mm track, 0.2 mm-    clearance, 0.5 mm copper-to-edge, plus every per-pad or per-footprint `(clearance X)` override.-  * A* (octilinear, 8 neighbours) per connection. Costs: 10 per orthogonal cell, 14 diagonal,-    x1.25 on B.Cu (prefer F.Cu), +400 per via (heavily penalised), +4 per direction change.-  * Obstacles per layer for the net being routed: every pad and every committed segment/via of-    another net, dilated by (that item's clearance + half the track width + grid slop); the-    outline inset by the edge clearance; through pads on both layers. Own-net copper is free.-    A diagonal step is allowed only when one of its ends also clears the larger dilation that-    covers the midpoint of the step, so 45-degree segments never clip a corner.-  * Narrow pads (fine-pitch IC pins) are entered on their long axis: a 0.2 mm stub from the exact-    pad centre to an on-grid escape point beyond either pad end; the route starts there. Other-    pads are hit at their centre. Same-net pads that overlap (MOSFET source pads) form one cluster.-  * A net with N clusters is routed as a minimum spanning tree: Prim's order, each new cluster-    routed to ANY copper the net already has (pads, tracks, vias), so branches land on their own-    copper and junctions split the existing segment at an on-grid point.-  * Order: nets by MST length, shortest first; plane-net stubs last.-  * Widths: +VBAT and the phase nets (/DRV_SHA, /DRV_SHB, /DRV_SHC, which are also the MOSFET-    drain and source nets) try 1.0 mm, then 0.5, then 0.25; +5V and +12V try 0.5 then 0.25;-    everything else 0.25. A pad narrower than the width caps it (a 1.0 mm track never enters a-    0.35 mm pin). Fallback happens per connection when the wider route finds no path.-  * GND and +3V3 are plane nets (In1.Cu and In2.Cu): no traces. Each SMD pad gets the shortest-    stub to a spot where a via fits (Dijkstra to the first via-legal cell), through pads need-    nothing. If no spot fits within 6 mm the via goes in the pad (reported).-  * Rip-up and retry: when a connection fails on every width, a second search treats other nets'-    tracks as expensive instead of solid; the nets it crosses are ripped up (most recently routed-    first), the connection is routed again, and the ripped nets are re-queued. Bounded per net-    and globally.--Output: a JSON plan, a list of kicad_route_net calls: {"net", "width", "viaSize", "viaDrill",-"paths": [[...], ...]} where a waypoint is "REF.PAD", [x, y] or {"x", "y", "layer": "B.Cu"}-(a layer marker = a via there). write_copper.py applies it offline, route_live.py replays it.-"""-import argparse-import heapq-import json-import math-import os-import sys-import time-from collections import defaultdict--import numpy as np--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board, Grid, F_CU, B_CU, LAYER_NAMES, RES  # noqa: E402--# ----------------------------------------------------------------------------- rules--RULES = {-    "track": 0.25, "clearance": 0.2, "via": 0.8, "drill": 0.4, "edge": 0.5,-    "stub": 0.2,               # escape stub width for narrow pins-    "narrow": 0.5,             # pads with a short side under this get an axis escape-    "escape": 0.45,            # escape point this far beyond the pad end (grows if blocked)-    "corridor": 0.5,           # reserved own-net corridor beyond the escape point (other nets keep out)-    "ic_zone": 2.0,            # soft-cost band around a fine-pitch IC (routes should not run along a pin row)-    "channel": 2.5,            # no via on the line of another reserved pin escape this far beyond the pad-    "planes": {"GND": "In1.Cu", "+3V3": "In2.Cu"},-    "wide": {"+VBAT": [1.0, 0.5, 0.25], "/DRV_SHA": [1.0, 0.5, 0.25], "/DRV_SHB": [1.0, 0.5, 0.25], "/DRV_SHC": [1.0, 0.5, 0.25],-             "GND_OUT": [1.0, 0.5, 0.25]},   # the battery return through the shunt carries the same current as +VBAT-    "mid": {"+5V": [0.5, 0.25], "+12V": [0.5, 0.25]},-    "via_small": 0.6, "drill_small": 0.3,   # signal vias; the wide power nets keep 0.8 / 0.4-    "hole_to_hole": 0.25,      # KiCad's default hole-to-hole minimum: same-net vias may sit this close (drill edge to drill edge)-    "via_search_mm": 6.0,-    # Kelvin sense taps: sense pad -> shunt pad. The sense pad gets NO plane stub and no share of the-    # net's wide copper; it gets one dedicated 0.25 mm trace on the pad's own layer to the shunt pad,-    # kept clear of every other piece of the same net (a pour keepout protects it afterwards).-    "kelvin": {},-}-SLOP = 0.05 * math.sqrt(2) + 1e-6   # half a cell diagonal: pads and stubs are off-grid-COST_ORTH, COST_DIAG, COST_VIA, COST_TURN = 10, 14, 250, 4-LAYER_MULT = {F_CU: 1.0, B_CU: 1.1}-SOFT_EXTRA = 80-ZONE_COST = 20      # per F.Cu cell inside a fine-pitch IC's escape band-VIA_WALL = 300      # per unrouted neighbour pin channel a via would wall in-HIST_COST = 12      # per cell per time a ripped-up route was there (contested corridors get pricier)-DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)]-STEP = [COST_ORTH] * 4 + [COST_DIAG] * 4-INF = 1 << 30---def log(*a):-    print(*a, flush=True)---# ----------------------------------------------------------------------------- data--class Cluster:-    """Pads of one net in one footprint whose copper overlaps: routed once."""--    def __init__(self, idx, pads):-        self.idx = idx-        self.pads = pads-        self.primary = max(pads, key=lambda p: p.half_long * p.half_short)-        self.terminals = []      # [(layer, i, j)] cells a route may start or end at-        self.escapes = {}        # (layer, i, j) -> escape point (x, y) exact grid coords (stub end)-        self.corridors = {}      # (layer, i, j) -> corridor end (x, y) beyond the escape point-        self.connected = False-        self.allow = 0.25--    @property-    def xy(self):-        return self.primary.x, self.primary.y--    @property-    def key(self):-        return self.primary.key---class Route:-    def __init__(self, cells, width, start, end):-        self.cells = cells          # [(layer, i, j)]-        self.width = width-        self.start = start          # ("pad", Cluster, term) | ("escape", Cluster, term)-        self.end = end              # ("pad", Cluster, term) | ("escape", ...) | ("copper",) | ("via",)-        self.breaks = set()         # indices into cells where a segment must end (junctions)-        self.via_end = False        # a via at the last cell (plane stub, or a via in the target pad)-        self.via_end_layer = "B.Cu" # the layer the via switches to-        self.kelvin = False         # a dedicated sense tap (kept apart from its own net; pour keepout later)---class NetState:-    def __init__(self, idx, name, pads):-        self.idx, self.name, self.pads = idx, name, pads-        self.clusters = []-        self.routes = []-        self.widths = [RULES["track"]]-        self.plane = RULES["planes"].get(name)-        self.mst_len = 0.0-        self.used_escapes = set()    # (cluster idx, terminal)-        self.failed = []             # [(cluster key, reason)]-        self.rips = 0-        self.routed_order = None-        self.via_in_pad = []-        self.last_soft = []-        self.via = RULES["via_small"]-        self.drill = RULES["drill_small"]---def _seg_distance(x, y, ax, ay, bx, by):-    dx, dy = bx - ax, by - ay-    L2 = dx * dx + dy * dy-    if L2 < 1e-12:-        return math.hypot(x - ax, y - ay)-    t = max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L2))-    return math.hypot(x - (ax + t * dx), y - (ay + t * dy))---def _point_poly_distance(x, y, poly):-    """0 inside the polygon, else the distance to its boundary."""-    inside = False-    n = len(poly)-    best = float("inf")-    for k in range(n):-        ax, ay = poly[k]-        bx, by = poly[(k + 1) % n]-        if (ay > y) != (by > y):-            xint = ax + (y - ay) * (bx - ax) / (by - ay)-            if x < xint:-                inside = not inside-        d = _seg_distance(x, y, ax, ay, bx, by)-        if d < best:-            best = d-    return 0.0 if inside else best---def _route_segments(g, cells):-    """Straight runs of a cell path as exact segments (x0, y0, x1, y1), vias skipped."""-    out = []-    k = 0-    n = len(cells)-    while k < n - 1:-        L, i0, j0 = cells[k]-        m = k + 1-        if cells[m][0] != L:-            k = m-            continue-        di, dj = cells[m][1] - i0, cells[m][2] - j0-        while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):-            m += 1-        a = g.to_xy(i0, j0)-        b = g.to_xy(cells[m][1], cells[m][2])-        out.append((a[0], a[1], b[0], b[1]))-        k = m-    return out---# ----------------------------------------------------------------------------- router--class Router:-    def __init__(self, board, verbose=False):-        self.board = board-        self.verbose = verbose-        self.grid = Grid(board.bbox)-        g = self.grid-        self.H, self.W = g.H, g.W-        self.nets = {}-        self.net_ids = {}-        for k, (name, pads) in enumerate(sorted(board.nets.items())):-            self.net_ids[name] = k-            self.nets[name] = NetState(k, name, pads)-        # raw copper by layer: pads (+ reserved stubs) and routes, net id or -1-        self.pad_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}-        self.pad_owner = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}-        self.route_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}-        self.big_clr = {L: np.zeros((g.H, g.W), dtype=bool) for L in (F_CU, B_CU)}  # pads with a 0.6 override-        self.big_clr_val = RULES["clearance"]-        self.smd_pad_cells = {}   # net id -> bool mask (F.Cu smd pads) for via placement-        self.thru_cells = np.zeros((g.H, g.W), dtype=bool)   # every through pad: never a via site-        self.history = {L: np.zeros((g.H, g.W), dtype=np.int16) for L in (F_CU, B_CU)}   # contested cells (rip-ups)-        self.chan_count = np.zeros((g.H, g.W), dtype=np.int16)   # how many unrouted pin channels a via here would wall-        self.chan_lines = {}      # (net idx, cluster idx, term) -> (jj, ii) of the walled region-        self.vias = []            # [(i, j, net id, diameter, drill)]-        self.kelvin_owner = {}    # synthetic net id of a Kelvin tap -> the real NetState (never ripped up)-        self.fp_index = {ref: n for n, ref in enumerate(sorted(board.footprints))}-        self.pad_cells = {}       # pad key -> (jj, ii)-        t0 = time.time()-        self._stamp_pads()-        self.inside, self.edge_dist = g.outline_distance(board.outline)-        self.inside_w = {}-        self.order = []-        self.rip_total = 0-        self.stats = defaultdict(int)-        self._build_clusters()-        self._build_ic_zones()-        log("router: %d x %d cells, %d pads stamped, %d nets to route, init %.1fs" % (-            g.W, g.H, len(board.pads), len([n for n in self.nets.values() if len(n.clusters) >= 2 or n.plane]), time.time() - t0))--    # -- stamping -------------------------------------------------------------------    def _stamp_pads(self):-        g = self.grid-        for p in self.board.pads:-            nid = self.net_ids.get(p.net, -1)-            m = np.zeros((g.H, g.W), dtype=bool)-            for poly in p.polys:-                g.poly_mask(poly, m)-            if p.drill:-                jj, ii = g.disk_cells((p.x, p.y), p.drill / 2.0)-                m[jj, ii] = True-            jj, ii = np.nonzero(m)-            self.pad_cells[p.key] = (jj, ii)-            layers = [F_CU, B_CU] if p.thru else [F_CU if "F.Cu" in p.layers else B_CU]-            for L in layers:-                self.pad_raw[L][jj, ii] = nid-                self.pad_owner[L][jj, ii] = self.fp_index[p.ref]-                if p.clearance > RULES["clearance"]:-                    self.big_clr[L][jj, ii] = True-                    self.big_clr_val = max(self.big_clr_val, p.clearance)-            if not p.thru and nid >= 0:-                sm = self.smd_pad_cells.setdefault(nid, np.zeros((g.H, g.W), dtype=bool))-                sm[jj, ii] = True-            if p.thru:-                self.thru_cells[jj, ii] = True--    def inside_for(self, w):-        key = round(w, 3)-        if key not in self.inside_w:-            self.inside_w[key] = self.inside & (self.edge_dist >= RULES["edge"] + w / 2.0 + SLOP)-        return self.inside_w[key]--    def stamp_route(self, net, route, sign=1):-        """Add (sign=1) or remove (sign=-1) a route's copper from route_raw."""-        g = self.grid-        nid = net.idx if sign > 0 else -1-        cells = route.cells-        k = 0-        while k < len(cells) - 1:-            # straight run from k-            L, i0, j0 = cells[k]-            m = k + 1-            di, dj = cells[m][1] - i0, cells[m][2] - j0-            while m + 1 < len(cells) and cells[m + 1][0] == L and cells[m][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):-                m += 1-            if cells[m][0] == L:-                a = g.to_xy(i0, j0)-                b = g.to_xy(cells[m][1], cells[m][2])-                jj, ii = g.segment_cells(a, b, route.width / 2.0, 0.0 + 1e-6)-                self.route_raw[L][jj, ii] = nid-            k = m-        # vias: layer changes-        for k in range(1, len(cells)):-            if cells[k][0] != cells[k - 1][0] and cells[k][1:] == cells[k - 1][1:]:-                self._stamp_via(cells[k][1], cells[k][2], net, sign)-        if route.via_end:-            L, i, j = cells[-1]-            self._stamp_via(i, j, net, sign)-        if len(cells) == 1 and route.via_end:-            pass--    def _stamp_via(self, i, j, net, sign):-        g = self.grid-        nid = net.idx-        xy = g.to_xy(i, j)-        jj, ii = g.disk_cells(xy, net.via / 2.0, 0.0 + 1e-6)-        for L in (F_CU, B_CU):-            self.route_raw[L][jj, ii] = nid if sign > 0 else -1-        if sign > 0:-            self.vias.append((i, j, nid, net.via, net.drill))-        else:-            self.vias = [v for v in self.vias if not (v[0] == i and v[1] == j and v[2] == nid)]--    def via_gap(self, net, nid, dia, drill):-        """Centre-to-centre distance this net's via must keep from a via of (nid, dia, drill)."""-        if nid == net.idx:-            return (net.drill + drill) / 2.0 + RULES["hole_to_hole"] + SLOP-        return net.via / 2.0 + dia / 2.0 + RULES["clearance"] + SLOP--    def stamp_stub(self, net, cluster, term, sign=1):-        """Reserve or release the escape stub copper (exact pad centre to the on-grid escape point)."""-        g = self.grid-        L, i, j = term-        p = cluster.primary-        a = p.polys_centre-        b = g.to_xy(i, j)-        jj, ii = g.segment_cells(a, b, RULES["stub"] / 2.0, 1e-6)-        self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1-        if sign > 0:-            self.pad_owner[L][jj, ii] = self.fp_index[p.ref]-        kend = cluster.corridors.get(term)-        if kend is not None:-            jj, ii = g.segment_cells(b, kend, RULES["track"] / 2.0, 1e-6)-            self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1-            if sign > 0:-                self.pad_owner[L][jj, ii] = self.fp_index[p.ref]-        self._stamp_channel(net, cluster, term, sign)-        if sign < 0:-            # restore the pad itself-            pj, pi = self.pad_cells[p.key]-            self.pad_raw[L][pj, pi] = net.idx-            self.pad_owner[L][pj, pi] = self.fp_index[p.ref]--    def _stamp_channel(self, net, cluster, term, sign):-        """Count (or uncount) the region where a via would wall in this unrouted pin's straight path."""-        g = self.grid-        key = (net.idx, cluster.idx, term)-        if sign > 0 and key in self.chan_lines:-            return-        if sign < 0:-            hit = self.chan_lines.pop(key, None)-            if hit is not None:-                self.chan_count[hit[0], hit[1]] -= 1-            return-        L, i, j = term-        p = cluster.primary-        b = g.to_xy(i, j)-        ax, ay = p.long_axis-        cx, cy = p.polys_centre-        sgn = 1.0 if (b[0] - cx) * ax + (b[1] - cy) * ay > 0 else -1.0-        cend = (b[0] + sgn * round(ax, 6) * RULES["channel"], b[1] + sgn * round(ay, 6) * RULES["channel"])-        r = net.via / 2.0 + RULES["clearance"] + RULES["track"] / 2.0 + SLOP-        jj, ii = g.segment_cells(b, cend, r, 0.0)-        ok = self.inside_for(net.via)[jj, ii]-        jj, ii = jj[ok], ii[ok]-        self.chan_count[jj, ii] += 1-        self.chan_lines[key] = (jj, ii)--    # -- clusters and terminals -----------------------------------------------------    def _build_clusters(self):-        g = self.grid-        for net in self.nets.values():-            if len(net.pads) < 2 and not net.plane:-                continue-            by_fp = defaultdict(list)-            for p in net.pads:-                by_fp[p.ref].append(p)-            clusters = []-            for ref, pads in by_fp.items():-                groups = []-                for p in pads:-                    mask = set(zip(*[a.tolist() for a in self.pad_cells[p.key]]))-                    merged = None-                    for gidx, (gpads, gcells) in enumerate(groups):-                        if mask & gcells:-                            if merged is None:-                                gpads.append(p)-                                gcells |= mask-                                merged = gidx-                            else:-                                groups[merged][0].extend(gpads)-                                groups[merged][1] |= gcells-                                groups[gidx] = None-                    groups = [x for x in groups if x is not None]-                    if merged is None:-                        groups.append(([p], set(mask)))-                for gpads, _ in groups:-                    clusters.append(Cluster(len(clusters), gpads))-            net.clusters = clusters-            if net.name in RULES["wide"]:-                net.widths = list(RULES["wide"][net.name])-                net.via, net.drill = RULES["via"], RULES["drill"]-            elif net.name in RULES["mid"]:-                net.widths = list(RULES["mid"][net.name])-            for c in clusters:-                self._terminals_for(net, c)-        # MOSFET drain/source nets get the wide treatment too-        for p in self.board.pads:-            if p.ref.startswith("Q") and p.name in ("1", "2", "3", "5") and p.net in self.nets:-                n = self.nets[p.net]-                if not n.plane and n.name not in RULES["wide"]:-                    n.widths = [1.0, 0.5, 0.25]-                    n.via, n.drill = RULES["via"], RULES["drill"]--    def _build_ic_zones(self):-        """Fine-pitch ICs (two or more escaped pins): the body interior on F.Cu is a keepout for nets-        without a pin on that IC; a band around the IC carries a soft cost for every net."""-        g = self.grid-        self.body_owner = np.full((g.H, g.W), -1, dtype=np.int16)-        self.ic_zone = np.zeros((g.H, g.W), dtype=bool)-        self.ic_nets = {}   # fp index -> set of net ids with a pin on it-        by_fp = defaultdict(list)-        for net in self.nets.values():-            for c in net.clusters:-                if c.escapes:-                    by_fp[c.primary.ref].append(c)-        for ref, clusters in by_fp.items():-            if len(clusters) < 2:-                continue-            fp = self.board.footprints[ref]-            inner = []-            for c in clusters:-                p = c.primary-                ax, ay = p.long_axis-                cx, cy = p.polys_centre-                sgn = 1.0 if (fp["x"] - cx) * ax + (fp["y"] - cy) * ay > 0 else -1.0-                inner.append((cx + sgn * ax * p.half_long, cy + sgn * ay * p.half_long))-            pads = [q for q in self.board.pads if q.ref == ref]-            xs = [pt[0] for pt in inner]-            ys = [pt[1] for pt in inner]-            if max(xs) - min(xs) < 1.0 or max(ys) - min(ys) < 1.0:-                continue-            i0, j0 = g.to_cell(min(xs), min(ys))-            i1, j1 = g.to_cell(max(xs), max(ys))-            idx = self.fp_index[ref]-            self.body_owner[j0:j1 + 1, i0:i1 + 1] = idx-            self.ic_nets[idx] = {self.net_ids[q.net] for q in pads if q.net in self.net_ids}-            m = int(round(RULES["ic_zone"] / RES))-            px = [q.x for q in pads]-            py = [q.y for q in pads]-            a0, b0 = g.to_cell(min(px), min(py))-            a1, b1 = g.to_cell(max(px), max(py))-            self.ic_zone[max(0, b0 - m):b1 + m + 1, max(0, a0 - m):a1 + m + 1] = True-            log("  ic %s: body keepout %.1f x %.1f mm for %d foreign nets, escape band +%d/cell" % (-                ref, (i1 - i0) * RES, (j1 - j0) * RES, len(self.nets) - len(self.ic_nets[idx]), ZONE_COST))--    def _terminals_for(self, net, c):-        g = self.grid-        p = c.primary-        md = p.min_dim-        c.allow = 1.0 if md >= 1.0 else (0.5 if md >= 0.5 else 0.25)-        if p.thru:-            i, j = g.to_cell(p.x, p.y)-            c.terminals = [(F_CU, i, j), (B_CU, i, j)]-            return-        L = F_CU if "F.Cu" in p.layers else B_CU-        narrow = md < RULES["narrow"] and p.half_long > p.half_short * 1.4-        if not narrow:-            i, j = g.to_cell(p.x, p.y)-            c.terminals = [(L, i, j)]-            return-        c.allow = 0.25-        cx, cy = p.polys_centre-        ax, ay = p.long_axis-        found = []-        for sgn in (1.0, -1.0):-            hit = None-            # a full corridor first; a shorter one when a neighbouring part sits close to the pin end-            for corridor in (RULES["corridor"], RULES["corridor"] / 2.0, 0.0):-                d = RULES["escape"]-                while d <= 1.6 and hit is None:-                    ex, ey = cx + sgn * ax * (p.half_long + d), cy + sgn * ay * (p.half_long + d)-                    i, j = g.to_cell(ex, ey)-                    # the corridor leaves the ON-GRID escape cell along the pin axis, so an axis-aligned-                    # pin row gets straight corridors that rasterise three cells wide, never four-                    bx, by = g.to_xy(i, j)-                    kx, ky = bx + sgn * round(ax, 6) * corridor, by + sgn * round(ay, 6) * corridor-                    if g.in_bounds(i, j) and self._escape_ok(net, p, L, i, j, (kx, ky)):-                        hit = ((L, i, j), sgn, (kx, ky) if corridor > 0 else None)-                    d += 0.1-                if hit is not None:-                    break-            if hit is not None:-                found.append(hit)-        c.terminals = [t for t, _, _ in found]-        for t, sgn, kend in found:-            c.escapes[t] = g.to_xy(t[1], t[2])-            if kend is not None:-                c.corridors[t] = kend-            self.stamp_stub(net, c, t, 1)-        if not found:-            i, j = g.to_cell(p.x, p.y)-            c.terminals = [(L, i, j)]--    def _escape_ok(self, net, p, L, i, j, corridor_end):-        """Exact geometry: the escape cell, the stub and the reserved corridor beyond it clear every-        pad of another footprint (same-footprint neighbours are the regular pin field, fine by-        construction), and the corridor end lies inside the outline."""-        g = self.grid-        if not self.inside_for(RULES["track"])[j, i]:-            return False-        b = g.to_xy(i, j)-        a = p.polys_centre-        checks = [(a, b, RULES["stub"] / 2.0)]-        if math.dist(b, corridor_end) > 1e-6:-            ki, kj = g.to_cell(*corridor_end)-            if not g.in_bounds(ki, kj) or not self.inside_for(RULES["track"])[kj, ki]:-                return False-            checks.append((b, corridor_end, RULES["track"] / 2.0))-        samples = []-        for (s0, s1, hw) in checks:-            n = max(1, int(math.ceil(math.dist(s0, s1) / 0.05)))-            for k in range(n + 1):-                t = k / n-                samples.append((s0[0] + (s1[0] - s0[0]) * t, s0[1] + (s1[1] - s0[1]) * t, hw))-        # the route leaving the escape cell: a track of the net's width in any direction-        samples.append((b[0], b[1], RULES["track"] / 2.0))-        reach = 2.5-        for q in self.board.pads:-            if q.ref == p.ref or q.net == net.name:-                continue-            if abs(q.x - b[0]) > reach + q.half_long or abs(q.y - b[1]) > reach + q.half_long:-                continue-            if L == F_CU and "F.Cu" not in q.layers:-                continue-            clr = max(RULES["clearance"], q.clearance)-            for poly in q.polys:-                for (x, y, hw) in samples:-                    if _point_poly_distance(x, y, poly) < hw + clr:-                        return False-        return True--    # -- obstacle maps ----------------------------------------------------------------    def obstacle_maps(self, net, width, win, soft=False):-        """(obs_o, obs_d, soft_mask) per layer for the window, plus via_ok. Windows are (i0, j0, i1, j1) inclusive."""-        g = self.grid-        i0, j0, i1, j1 = win-        r_o = (RULES["clearance"] + width / 2.0 + SLOP) / RES-        r_d = r_o + 0.0707 / RES-        r_big = (self.big_clr_val + width / 2.0 + SLOP) / RES-        pad_R = int(math.ceil(max(r_d, r_big))) + 1-        # work on a padded window so the dilation sees copper just outside it-        I0, J0 = max(0, i0 - pad_R), max(0, j0 - pad_R)-        I1, J1 = min(self.W - 1, i1 + pad_R), min(self.H - 1, j1 + pad_R)-        obs_o, obs_d, softm = {}, {}, {}-        via_block = np.zeros((J1 - J0 + 1, I1 - I0 + 1), dtype=bool)-        for L in (F_CU, B_CU):-            praw = self.pad_raw[L][J0:J1 + 1, I0:I1 + 1]-            rraw = self.route_raw[L][J0:J1 + 1, I0:I1 + 1]-            other_pads = (praw != -1) & (praw != net.idx)-            other_routes = (rraw != -1) & (rraw != net.idx)-            big = self.big_clr[L][J0:J1 + 1, I0:I1 + 1] & other_pads-            hard = other_pads if soft else (other_pads | other_routes)-            o = g.dilate(hard, r_o) | g.dilate(big, r_big) | ~self.inside_for(width)[J0:J1 + 1, I0:I1 + 1]-            d = g.dilate(hard, r_d) | g.dilate(big, r_big + 0.0707 / RES)-            if L == F_CU and self.ic_nets:-                foreign = [idx for idx, nets in self.ic_nets.items() if net.idx not in nets]-                if foreign:-                    body = np.isin(self.body_owner[J0:J1 + 1, I0:I1 + 1], foreign)-                    o |= body-                    d |= body-            s = g.dilate(other_routes, r_d) if soft else None-            sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))-            obs_o[L] = o[sl]-            obs_d[L] = d[sl]-            softm[L] = s[sl] if s is not None else None-            via_block |= g.dilate(other_pads | other_routes, (RULES["clearance"] + net.via / 2.0 + SLOP) / RES)-            via_block |= g.dilate(big, (self.big_clr_val + net.via / 2.0 + SLOP) / RES)-        own_smd = self.smd_pad_cells.get(net.idx)-        if own_smd is not None:-            via_block |= g.dilate(own_smd[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)-        via_block |= g.dilate(self.thru_cells[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)-        # other vias: annular ring + clearance for another net, hole to hole for this net-        groups = defaultdict(list)-        for (vi, vj, nid, dia, drill) in self.vias:-            if I0 <= vi <= I1 and J0 <= vj <= J1:-                groups[round(self.via_gap(net, nid, dia, drill), 4)].append((vj - J0, vi - I0))-        for gap, cells in groups.items():-            vm = np.zeros_like(via_block)-            for (a, b) in cells:-                vm[a, b] = True-            via_block |= g.dilate(vm, gap / RES - 1e-6)-        via_ok = ~via_block & self.inside_for(net.via)[J0:J1 + 1, I0:I1 + 1]-        sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))-        return obs_o, obs_d, softm, via_ok[sl]--    # -- A* ---------------------------------------------------------------------------    def search(self, net, width, sources, targets, heur_pts, win, allow_via=True, soft=False, via_target=False, max_nodes=None, vip=()):-        """A* from sources to targets. sources: [(L,i,j,cost0)]; targets: {(L,i,j)} or None when via_target.-        Returns (cells, blockers) with cells [(L,i,j)] or None."""-        i0, j0, i1, j1 = win-        Ww, Hw = i1 - i0 + 1, j1 - j0 + 1-        NL = Ww * Hw-        obs_o, obs_d, softm, via_ok = self.obstacle_maps(net, width, win, soft)-        oo = {L: obs_o[L].ravel().tobytes() for L in (F_CU, B_CU)}-        od = {L: obs_d[L].ravel().tobytes() for L in (F_CU, B_CU)}-        sm = {L: (softm[L].ravel().tobytes() if softm[L] is not None else None) for L in (F_CU, B_CU)}-        vo = via_ok.ravel().tobytes()-        zone = self.ic_zone[j0:j1 + 1, i0:i1 + 1].ravel().tobytes()-        wallm = self.chan_count[j0:j1 + 1, i0:i1 + 1].astype(np.int32)-        for (nid, cidx, term), (cj, ci) in self.chan_lines.items():-            if nid == net.idx:-                sel = (cj >= j0) & (cj <= j1) & (ci >= i0) & (ci <= i1)-                wallm[cj[sel] - j0, ci[sel] - i0] -= 1-        wall = np.clip(wallm, 0, 60).astype(np.uint8).ravel().tobytes()-        hist = {L: np.clip(self.history[L][j0:j1 + 1, i0:i1 + 1], 0, 40).astype(np.uint8).ravel().tobytes() for L in (F_CU, B_CU)}-        tgt = bytearray(2 * NL)-        vipset = set()-        for (L, i, j) in vip:-            if i0 <= i <= i1 and j0 <= j <= j1:-                vipset.add((j - j0) * Ww + (i - i0))-        if via_target:-            for L in (F_CU,):-                base = L * NL-                for m in range(NL):-                    if vo[m] and not oo[L][m]:-                        tgt[base + m] = 1-        if targets:-            for (L, i, j) in targets:-                if i0 <= i <= i1 and j0 <= j <= j1:-                    tgt[L * NL + (j - j0) * Ww + (i - i0)] = 1-        if not any(tgt):-            return None, set()-        gcost = [INF] * (2 * NL)-        parent = [-1] * (2 * NL)-        pdir = [-1] * (2 * NL)-        closed = bytearray(2 * NL)-        heap = []-        hp = [(i - i0, j - j0) for (i, j) in heur_pts]--        def h(il, jl):-            best = INF-            for (hi, hj) in hp:-                dx, dy = abs(hi - il), abs(hj - jl)-                v = 10 * max(dx, dy) + 4 * min(dx, dy)-                if v < best:-                    best = v-            return best if hp else 0--        for (L, i, j, c0) in sources:-            if not (i0 <= i <= i1 and j0 <= j <= j1):-                continue-            m = (j - j0) * Ww + (i - i0)-            n = L * NL + m-            if oo[L][m]:-                continue-            if c0 < gcost[n]:-                gcost[n] = c0-                heapq.heappush(heap, (c0 + h(i - i0, j - j0), n))-        expanded = 0-        found = -1-        while heap:-            f, n = heapq.heappop(heap)-            if closed[n]:-                continue-            closed[n] = 1-            if tgt[n]:-                found = n-                break-            expanded += 1-            if max_nodes and expanded > max_nodes:-                break-            L = n // NL-            m = n - L * NL-            jl = m // Ww-            il = m - jl * Ww-            gn = gcost[n]-            pd = pdir[n]-            ooL, odL, smL, hiL = oo[L], od[L], sm[L], hist[L]-            mult = LAYER_MULT[L]-            for d in range(8):-                ni = il + DIRS[d][0]-                nj = jl + DIRS[d][1]-                if ni < 0 or nj < 0 or ni >= Ww or nj >= Hw:-                    continue-                nm = nj * Ww + ni-                if ooL[nm]:-                    continue-                if d >= 4 and odL[nm] and odL[m]:-                    continue-                nn = L * NL + nm-                if closed[nn]:-                    continue-                c = STEP[d] * mult-                if pd != -1 and pd != d:-                    c += COST_TURN-                if smL is not None and smL[nm]:-                    c += SOFT_EXTRA-                if L == F_CU and zone[nm]:-                    c += ZONE_COST-                if hiL[nm]:-                    c += HIST_COST * hiL[nm]-                ng = gn + c-                if ng < gcost[nn]:-                    gcost[nn] = ng-                    parent[nn] = n-                    pdir[nn] = d-                    heapq.heappush(heap, (ng + h(ni, nj), nn))-            if allow_via and (vo[m] or m in vipset):-                L2 = 1 - L-                if not oo[L2][m]:-                    nn = L2 * NL + m-                    if not closed[nn]:-                        ng = gn + COST_VIA + VIA_WALL * wall[m]-                        if ng < gcost[nn]:-                            gcost[nn] = ng-                            parent[nn] = n-                            pdir[nn] = -1-                            heapq.heappush(heap, (ng + h(il, jl), nn))-        self.stats["expanded"] += expanded-        if found < 0:-            return None, set()-        cells = []-        n = found-        while n != -1:-            L = n // NL-            m = n - L * NL-            jl = m // Ww-            il = m - jl * Ww-            cells.append((L, il + i0, jl + j0))-            n = parent[n]-        cells.reverse()-        blockers = set()-        if soft:-            r = int(math.ceil((RULES["clearance"] + width / 2.0 + SLOP) / RES)) + 1-            for (L, i, j) in cells:-                if sm[L] is not None and sm[L][(j - j0) * Ww + (i - i0)]:-                    sub = self.route_raw[L][max(0, j - r):j + r + 1, max(0, i - r):i + r + 1]-                    for v in np.unique(sub):-                        if v != -1 and v != net.idx:-                            blockers.add(int(v))-        return cells, blockers--    # -- per net ------------------------------------------------------------------------    def window_for(self, pts, margin_mm):-        m = int(round(margin_mm / RES))-        xs = [p[0] for p in pts]-        ys = [p[1] for p in pts]-        return (max(0, min(xs) - m), max(0, min(ys) - m), min(self.W - 1, max(xs) + m), min(self.H - 1, max(ys) + m))--    def copper_targets(self, net):-        """Every cell of the net's connected copper: connected clusters' terminals and route centrelines."""-        t = set()-        for c in net.clusters:-            if c.connected:-                t.update(c.terminals)-        for r in net.routes:-            t.update(r.cells)-        return t--    def vip_cells(self, net, clusters):-        """Direct SMD pad terminals where a via may sit in the pad (clear of every other net)."""-        out = []-        for c in clusters:-            if c.primary.thru or c.escapes:-                continue-            for t in c.terminals:-                if t[0] == F_CU and self._via_clear_exact(net, *self.grid.to_xy(t[1], t[2])):-                    out.append(t)-        return out--    def connect(self, net, cluster, anchor_pts, soft=False, vip=False):-        """Route `cluster` to the net's existing copper. Returns (Route|None, blockers).--        vip=True is the last resort before rip-up: a via may sit in the pad of the cluster being-        routed or in the pad it lands on when every exit on F.Cu is walled in."""-        targets = self.copper_targets(net)-        sources = []-        for t in cluster.terminals:-            sources.append((t[0], t[1], t[2], 0))-        widths = [w for w in net.widths if w <= cluster.allow] or [min(net.widths)]-        src_pts = [(t[1], t[2]) for t in cluster.terminals]-        allpts = src_pts + list(anchor_pts)-        vip_src, vip_tgt = [], []-        if vip:-            vip_src = self.vip_cells(net, [cluster])-            vip_tgt = self.vip_cells(net, [c for c in net.clusters if c.connected])-            targets = set(targets) | {(B_CU, t[1], t[2]) for t in vip_tgt}-        if soft:-            stages = [(12.0, 200000)]-        elif vip:-            stages = [(12.0, 200000)]-        else:-            stages = [(5.0, None), (12.0, None), (80.0, 250000)]-        for width in widths:-            for margin, cap in stages:-                win = self.window_for(allpts, margin)-                cells, blockers = self.search(net, width, sources, targets, anchor_pts, win, soft=soft, max_nodes=cap, vip=vip_src)-                if cells:-                    start_term = cells[0]-                    end_cell = cells[-1]-                    start = ("escape", cluster, start_term) if start_term in cluster.escapes else ("pad", cluster, start_term)-                    end = ("copper",)-                    for c in net.clusters:-                        if c.connected and (end_cell in c.terminals or (end_cell[0] == B_CU and (F_CU, end_cell[1], end_cell[2]) in c.terminals and end_cell in targets)):-                            end = ("escape", c, end_cell) if end_cell in c.escapes else ("pad", c, end_cell)-                    route = Route(cells, width, start, end)-                    if end[0] == "pad" and end_cell[0] == B_CU and not end[1].primary.thru:-                        route.via_end = True-                        route.via_end_layer = "F.Cu"-                        self.stats["viaInPad"] += 1-                        net.via_in_pad.append(end[1].key)-                    if len(cells) > 1 and cells[0][1:] == cells[1][1:] and cells[0][0] != cells[1][0] and not cluster.primary.thru:-                        self.stats["viaInPad"] += 1-                        net.via_in_pad.append(cluster.key)-                    return route, blockers-        return None, set()--    def route_net(self, net):-        """Prim's order over clusters; each cluster to the existing copper. Returns list of blocker net ids on failure."""-        clusters = [c for c in net.clusters]-        for c in clusters:-            c.connected = False-        net.routes = []-        net.failed = []-        net.last_soft = []-        # start from the biggest pad (the trunk grows from the power pads)-        start = max(clusters, key=lambda c: (c.allow, c.primary.half_long * c.primary.half_short))-        start.connected = True-        connected = [start]-        remaining = [c for c in clusters if c is not start]-        blockers_all = set()-        # Kelvin taps first: sense pad to shunt pad as a dedicated trace, then the rest of the net treats-        # that trace as foreign copper so the wide current path never merges with it-        for c in list(remaining):-            if c.key in RULES["kelvin"]:-                dst = next((k for k in clusters if k.key == RULES["kelvin"][c.key]), None)-                if dst is None:-                    continue-                if self.route_kelvin(net, c, dst):-                    remaining.remove(c)-                    connected.append(c)-                    if dst in remaining:-                        remaining.remove(dst)-                        connected.append(dst)-                else:-                    net.failed.append((c.key, "kelvin tap to %s found no dedicated path" % dst.key))-                    remaining.remove(c)-        while remaining:-            best = None-            for c in remaining:-                for k in connected:-                    d = math.dist(c.xy, k.xy)-                    if best is None or d < best[0]:-                        best = (d, c, k)-            d, c, k = best-            remaining.remove(c)-            anchor = [(t[1], t[2]) for t in k.terminals]-            route, _ = self.connect(net, c, anchor)-            if route is None:-                route, _ = self.connect(net, c, anchor, vip=True)-            if route is None:-                soft_route, blockers = self.connect(net, c, anchor, soft=True)-                blockers_all |= blockers-                if soft_route is not None:-                    net.last_soft.extend(soft_route.cells)-                net.failed.append((c.key, "no path" if not blockers else "blocked by %s" % sorted(self.name_of(b) for b in blockers)))-                c.connected = False-                continue-            self.commit(net, route)-            c.connected = True-            connected.append(c)-        return blockers_all--    def name_of(self, nid):-        if nid in self.kelvin_owner:-            return self.kelvin_owner[nid].name-        for n in self.nets.values():-            if n.idx == nid:-                return n.name-        return "?"--    def commit(self, net, route):-        # junction: if the route ends inside another route of this net, split it there-        end = route.cells[-1]-        for r in net.routes:-            if end in r.cells:-                r.breaks.add(r.cells.index(end))-        for term in (route.start, route.end):-            if term[0] == "escape":-                net.used_escapes.add((term[1].idx, term[2]))-        net.routes.append(route)-        self.stamp_route(net, route, 1)-        # a routed cluster no longer needs its other escape: release that reservation-        for term in (route.start, route.end):-            if term[0] in ("escape", "pad"):-                self.release_escapes(net, term[1])-            if term[0] == "escape":-                self._stamp_channel(net, term[1], term[2], -1)--    def release_escapes(self, net, cluster):-        for t in list(cluster.escapes):-            if (cluster.idx, t) not in net.used_escapes and (cluster.idx, t) not in cluster.__dict__.setdefault("released", set()):-                self.stamp_stub(net, cluster, t, -1)-                cluster.released.add((cluster.idx, t))--    def reserve_escapes(self, net, cluster):-        """Re-reserve a cluster's escapes after a rip-up. An escape that was released and has since-        been crossed by another net's copper (a via dropped into the freed corridor) stays dead:-        re-stamping it would let the next route leave through that copper."""-        g = self.grid-        for t in list(cluster.escapes):-            if (cluster.idx, t) in cluster.__dict__.get("released", set()):-                L, i, j = t-                a = cluster.primary.polys_centre-                b = g.to_xy(i, j)-                pts = [(a, b, RULES["stub"] / 2.0 + RULES["clearance"])]-                kend = cluster.corridors.get(t)-                if kend is not None:-                    pts.append((b, kend, RULES["track"] / 2.0 + RULES["clearance"]))-                blocked = False-                for (s0, s1, hw) in pts:-                    jj, ii = g.segment_cells(s0, s1, hw, 1e-6)-                    raw = self.route_raw[L][jj, ii]-                    if np.any((raw != -1) & (raw != net.idx)):-                        blocked = True-                        break-                if blocked:-                    if t in cluster.terminals and len(cluster.terminals) > 1:-                        cluster.terminals.remove(t)-                    self.stats["deadEscapes"] += 1-                    continue-                self.stamp_stub(net, cluster, t, 1)-                cluster.released.discard((cluster.idx, t))-            else:-                self._stamp_channel(net, cluster, t, 1)--    def rip_cluster(self, net, cluster):-        """Rip the stub of one plane-net cluster (its route to the plane) so a signal can pass; the-        cluster is re-queued as a plane retry."""-        keep = []-        for r in net.routes:-            if r.start[1] is cluster:-                self.stamp_route(net, r, -1)-                for (L, i, j) in r.cells:-                    self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1-            else:-                keep.append(r)-        net.routes = keep-        net.used_escapes = {(ci, t) for (ci, t) in net.used_escapes if ci != cluster.idx}-        cluster.connected = False-        self.reserve_escapes(net, cluster)-        if cluster.key not in {k for k, _ in net.failed}:-            net.failed.append((cluster.key, "ripped for a signal"))-        self.stats["rips"] += 1-        self.rip_total += 1--    def rip(self, net):-        g = self.grid-        for r in net.routes:-            self.stamp_route(net, r, -1)-            # remember where the contested copper was: a little dearer for every later search-            for (L, i, j) in r.cells:-                self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1-        net.routes = []-        net.used_escapes = set()-        for c in net.clusters:-            c.connected = False-            self.reserve_escapes(net, c)-        net.rips += 1-        self.rip_total += 1-        self.stats["rips"] += 1--    # -- Kelvin taps --------------------------------------------------------------------------    def route_kelvin(self, net, src, dst):-        """One dedicated trace from the sense pad `src` to the shunt pad `dst` (same net): every other-        piece of this net is an obstacle for the search (a synthetic net id owns the two pads and the-        trace), no vias (a via would tie the tap to the plane), narrow width, the pad's own layer."""-        g = self.grid-        kid = 30000 + (src.idx % 1000)   # a net id no board net uses; int16 raw maps hold it-        while any(n.idx == kid for n in self.nets.values()):-            kid += 1-        fake = NetState(kid, net.name, [])-        self.kelvin_owner[kid] = net-        saved = []-        for cl in (src, dst):-            p = cl.primary-            jj, ii = g.disk_cells((p.x, p.y), p.half_long + RULES["escape"] + RULES["corridor"] + 0.3, 1e-6)-            for L in (F_CU, B_CU):-                m = self.pad_raw[L][jj, ii] == net.idx-                saved.append((L, jj[m], ii[m]))-                self.pad_raw[L][jj[m], ii[m]] = kid-        try:-            sources = [(t[0], t[1], t[2], 0) for t in src.terminals]-            targets = set(dst.terminals)-            pts = [(t[1], t[2]) for t in src.terminals] + [(t[1], t[2]) for t in dst.terminals]-            cells = None-            for margin in (4.0, 10.0):-                win = self.window_for(pts, margin)-                # a via on a plane-net tap would tie it to the plane; a tap on a signal net may change layer-                cells, _ = self.search(fake, RULES["stub"] + 0.05, sources, targets, [(t[1], t[2]) for t in dst.terminals], win, allow_via=not net.plane)-                if cells:-                    break-        finally:-            for (L, pj, pi) in saved:-                self.pad_raw[L][pj, pi] = net.idx-        if not cells:-            return False-        start = ("escape", src, cells[0]) if cells[0] in src.escapes else ("pad", src, cells[0])-        end = ("escape", dst, cells[-1]) if cells[-1] in dst.escapes else ("pad", dst, cells[-1])-        r = Route(cells, RULES["stub"] + 0.05, start, end)-        r.kelvin = True-        self.commit(net, r)-        # the trace is foreign copper to the rest of its own net from here on-        for (L, i, j) in cells:-            jj, ii = g.disk_cells(g.to_xy(i, j), r.width / 2.0, 1e-6)-            self.route_raw[L][jj, ii] = kid-        src.connected = True-        dst.connected = True-        self.stats["kelvin"] += 1-        log("    kelvin %s -> %s: %d cells, dedicated" % (src.key, dst.key, len(cells)))-        return True--    # -- planes ---------------------------------------------------------------------------    def route_plane_net(self, net, retry=False):-        g = self.grid-        if retry:-            failed = {k for k, _ in net.failed}-            net.failed = []-        for c in net.clusters:-            if c.primary.thru:-                c.connected = True-                continue-            if c.key in RULES["kelvin"]:-                continue   # a Kelvin sense pad never gets a plane stub; route_kelvin gives it its trace-            if retry and (c.connected or c.key not in failed):-                continue-            sources = [(t[0], t[1], t[2], 0) for t in c.terminals]-            win = self.window_for([(t[1], t[2]) for t in c.terminals], RULES["via_search_mm"])-            # a via site, or the net's own copper already on its way to the plane (a neighbour's stub)-            own = {cell for cell in self.copper_targets(net) if cell[0] == F_CU}-            cells, _ = self.search(net, RULES["track"], sources, own, [], win, allow_via=False, via_target=True)-            if cells is None:-                # via in pad: the pad centre if a via there clears every other net-                i, j = g.to_cell(*c.xy)-                ok = self._via_clear_exact(net, c.primary.x, c.primary.y)-                if ok:-                    r = Route([(F_CU, i, j)], RULES["track"], ("pad", c, (F_CU, i, j)), ("via",))-                    r.via_end = True-                    self.commit(net, r)-                    c.connected = True-                    net.via_in_pad.append(c.key)-                    self.stats["viaInPad"] += 1-                else:-                    net.failed.append((c.key, "no via site within %.0f mm" % RULES["via_search_mm"]))-                continue-            start_term = cells[0]-            start = ("escape", c, start_term) if start_term in c.escapes else ("pad", c, start_term)-            joined = cells[-1] in own-            r = Route(cells, RULES["track"], start, ("copper",) if joined else ("via",))-            r.via_end = not joined-            self.commit(net, r)-            c.connected = True--    def _via_clear_exact(self, net, x, y):-        """Exact geometry: may a via of this net sit at (x, y)? Pads, routes and vias of other nets,-        every through pad, the outline and existing vias of any net are checked with real distances."""-        g = self.grid-        rv = net.via / 2.0-        i, j = g.to_cell(x, y)-        if not g.in_bounds(i, j) or not self.inside_for(net.via)[j, i]:-            return False-        reach = rv + self.big_clr_val + 1.5-        for p in self.board.pads:-            if abs(p.x - x) > reach + p.half_long or abs(p.y - y) > reach + p.half_long:-                continue-            if p.net == net.name and not p.thru:-                continue-            if p.thru and p.net == net.name:-                need = net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0 + SLOP   # same net: the holes must not meet-            else:-                need = rv + max(RULES["clearance"], p.clearance)-            if p.thru and p.net != net.name:-                need = max(need, net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0)-            for poly in p.polys:-                if _point_poly_distance(x, y, poly) < need:-                    return False-        for other in self.nets.values():-            if other.idx == net.idx:-                continue-            for r in other.routes:-                need = rv + RULES["clearance"] + r.width / 2.0-                for (ax, ay, bx, by) in _route_segments(g, r.cells):-                    if abs(ax - x) > 3 and abs(bx - x) > 3:-                        continue-                    if _seg_distance(x, y, ax, ay, bx, by) < need:-                        return False-        for (vi, vj, nid, dia, drill) in self.vias:-            vx, vy = g.to_xy(vi, vj)-            if math.hypot(vx - x, vy - y) < self.via_gap(net, nid, dia, drill):-                return False-        return True--    def _via_clear_other(self, net, i, j):-        g = self.grid-        r = (RULES["clearance"] + net.via / 2.0 + SLOP) / RES-        R = int(math.ceil(r)) + 1-        for L in (F_CU, B_CU):-            for raw in (self.pad_raw[L], self.route_raw[L]):-                sub = raw[max(0, j - R):j + R + 1, max(0, i - R):i + R + 1]-                jj, ii = np.nonzero((sub != -1) & (sub != net.idx))-                for a, b in zip(jj, ii):-                    if (a + max(0, j - R) - j) ** 2 + (b + max(0, i - R) - i) ** 2 <= r * r:-                        return False-        for (vi, vj, nid, dia, drill) in self.vias:-            if math.hypot(vi - i, vj - j) * RES < self.via_gap(net, nid, dia, drill):-                return False-        return bool(self.inside_for(net.via)[j, i])--    # -- driver ---------------------------------------------------------------------------    def mst_length(self, net):-        pts = [c.xy for c in net.clusters]-        if len(pts) < 2:-            return 0.0-        inn = [pts[0]]-        rest = pts[1:]-        total = 0.0-        while rest:-            best = min(((math.dist(a, b), b) for a in inn for b in rest), key=lambda x: x[0])-            total += best[0]-            inn.append(best[1])-            rest.remove(best[1])-        return total--    def run(self, only=None, planes=True, rip_per_net=8, rip_global=400, priority=None):-        t0 = time.time()-        self.rip_total = 0-        for n in self.nets.values():-            n.rips = 0-        if planes:-            # plane stubs first: the shortest connections on the board, and their vias then shape-            # the signal routing instead of hunting for holes in it afterwards-            for name in RULES["planes"]:-                net = self.nets.get(name)-                if net is None or (only is not None and name not in only):-                    continue-                t1 = time.time()-                for c in net.clusters:-                    if c.key in RULES["kelvin"]:-                        dst = next((k for k in net.clusters if k.key == RULES["kelvin"][c.key]), None)-                        if dst is None or not self.route_kelvin(net, c, dst):-                            net.failed.append((c.key, "kelvin tap found no dedicated path"))-                self.route_plane_net(net)-                log("  %-28s plane %s: %d smd stubs, %d via-in-pad, %d failed, %.1fs" % (-                    net.name, net.plane, len(net.routes) - len(net.via_in_pad), len(net.via_in_pad), len(net.failed), time.time() - t1))-        signal = [n for n in self.nets.values() if not n.plane and len(n.clusters) >= 2 and (only is None or n.name in only)]-        for n in signal:-            n.mst_len = self.mst_length(n)-        first = [n for n in signal if n.name in (priority or ())]-        rest = [n for n in signal if n.name not in (priority or ())]-        queue = sorted(first, key=lambda n: n.mst_len) + sorted(rest, key=lambda n: n.mst_len)-        log("routing %d signal nets, shortest first (%.1f mm .. %.1f mm MST)%s" % (-            len(queue), queue[0].mst_len if queue else 0, queue[-1].mst_len if queue else 0,-            "" if not first else ", %d nets that failed last pass go first: %s" % (len(first), [n.name for n in first])))-        done = []-        order_no = 0-        best_state = None-        while queue:-            net = queue.pop(0)-            if net.routes:-                self.rip(net)-            t1 = time.time()-            blockers = self.route_net(net)-            order_no += 1-            net.routed_order = order_no-            log("  %-28s %2d clusters  %s  %d routes, %d failed, %.1fs%s" % (-                net.name, len(net.clusters), "/".join("%.2g" % w for w in net.widths), len(net.routes), len(net.failed), time.time() - t1,-                "" if not net.failed else "  FAILED %s" % net.failed))-            score = sum(len(n.failed) for n in self.nets.values() if n not in queue) + sum(max(0, len(n.clusters) - 1) for n in queue)-            if best_state is None or score < best_state[0]:-                best_state = (score, self.snapshot())-            if net.failed and blockers and net.rips < rip_per_net and self.rip_total < rip_global:-                cands = sorted((self.nets[self.name_of(b)] for b in blockers if b not in self.kelvin_owner and self.name_of(b) in self.nets and self.nets[self.name_of(b)].routes and not self.nets[self.name_of(b)].plane),-                               key=lambda n: -(n.routed_order or 0))[:4]-                stubs = []-                soft = set()-                for (L, i, j) in net.last_soft:-                    for di in range(-6, 7):-                        for dj in range(-6, 7):-                            soft.add((i + di, j + dj))-                # plane stubs are ripped only when nothing else blocks: a stub moves easily, a-                # signal net that has to be re-routed around a moved stub does not-                for b in (blockers if not cands else ()):-                    pn = self.nets[self.name_of(b)]-                    if not pn.plane:-                        continue-                    for r in list(pn.routes):-                        if any((i, j) in soft for (_, i, j) in r.cells):-                            stubs.append((pn, r.start[1]))-                if cands or stubs:-                    log("    rip-up %s%s and retry %s" % ([c.name for c in cands], "" if not stubs else " + plane stubs %s" % [c.key for _, c in stubs], net.name))-                    for c in cands:-                        self.rip(c)-                        if c in queue:-                            queue.remove(c)-                    for pn, cl in stubs:-                        self.rip_cluster(pn, cl)-                    net.rips += 1-                    queue.insert(0, net)-                    for c in cands:-                        queue.insert(1, c)-                    continue-            # a signal landed: give any ripped plane stubs their spot back right away-            for name in RULES["planes"]:-                pn = self.nets.get(name)-                if pn is not None and pn.failed and planes:-                    self.route_plane_net(pn, retry=True)-            done.append(net)-        if planes:-            # second chance for plane stubs that were walled in by reservations the signal routing has since released-            for name in RULES["planes"]:-                net = self.nets.get(name)-                if net is None or not net.failed or (only is not None and name not in only):-                    continue-                before = len(net.failed)-                self.route_plane_net(net, retry=True)-                log("  %-28s plane retry: %d of %d stubs recovered" % (net.name, before - len(net.failed), before))-        final = sum(len(n.failed) for n in self.nets.values())-        if best_state is not None and best_state[0] < final:-            log("  restoring the best intermediate state (%d failed connections instead of %d)" % (best_state[0], final))-            self.restore(best_state[1])-            if planes:-                for name in RULES["planes"]:-                    pn = self.nets.get(name)-                    if pn is not None and pn.failed:-                        self.route_plane_net(pn, retry=True)-            final = sum(len(n.failed) for n in self.nets.values())-        log("routing done in %.1fs, %d node expansions, %d rip-ups, %d failed connections" % (time.time() - t0, self.stats["expanded"], self.stats["rips"], final))-        return final--    def reset(self):-        """Drop every route and reservation state so a fresh pass can start (history is kept)."""-        for net in self.nets.values():-            if net.routes:-                self.rip(net)-            net.failed = []-            net.via_in_pad = []-            net.routed_order = None-            net.used_escapes = set()-            for c in net.clusters:-                c.connected = False-        self.rip_total = 0--    def snapshot(self):-        return {name: (list(n.routes), set(n.used_escapes), list(n.failed), list(n.via_in_pad), n.routed_order) for name, n in self.nets.items()}--    def restore(self, snap):-        self.reset()-        for name, (routes, used, failed, vip, order) in snap.items():-            net = self.nets[name]-            net.used_escapes = set(used)-            net.failed = list(failed)-            net.via_in_pad = list(vip)-            net.routed_order = order-            for r in routes:-                net.routes.append(r)-                self.stamp_route(net, r, 1)-            for c in net.clusters:-                c.connected = any(r.start[1] is c or (r.end[0] in ("pad", "escape") and r.end[1] is c) for r in routes) or (net.plane and c.primary.thru)--    # -- plan emission ------------------------------------------------------------------------    def _waypoints(self, net, route):-        g = self.grid-        cells = route.cells-        pts = []--        def xy(c):-            return list(g.to_xy(c[1], c[2]))--        # start-        kind, cl, term = route.start-        if kind == "pad":-            if term[0] == B_CU:-                pts.append({"pad": cl.primary.key, "layer": "B.Cu"})-            else:-                pts.append(cl.primary.key)-        else:-            pts.append(xy(cells[0]))-        # middle: collinear merge with forced breaks and layer changes-        k = 1-        n = len(cells)-        while k < n:-            L, i, j = cells[k]-            pL, pi, pj = cells[k - 1]-            if L != pL:-                # via at this cell-                pts.append({"x": xy(cells[k])[0], "y": xy(cells[k])[1], "layer": LAYER_NAMES[L]})-                k += 1-                continue-            di, dj = i - pi, j - pj-            m = k-            while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj) and m not in route.breaks:-                m += 1-            if m == n - 1:-                break-            pts.append(xy(cells[m]))-            k = m + 1-        # end-        last = cells[-1]-        ek = route.end[0]-        if route.via_end:-            p = xy(last)-            if len(cells) == 1 and kind == "pad":-                p = [cl.primary.x, cl.primary.y]-            pts.append({"x": p[0], "y": p[1], "layer": route.via_end_layer})-            if ek == "pad":-                pts.append(route.end[1].primary.key)-        elif ek == "pad":-            cl = route.end[1]-            if pts and isinstance(pts[-1], dict) and pts[-1].get("layer") and len(cells) > 1 and cells[-1][1:] == cells[-2][1:]:-                pass-            pts.append(cl.primary.key)-        else:-            pts.append(xy(last))-        # a via exactly at the last cell followed by the pad name: keep both (the marker then the pad)-        return pts--    def plan(self):-        entries = []-        g = self.grid-        for net in sorted(self.nets.values(), key=lambda n: (n.routed_order or 10 ** 6, n.name)):-            if not net.routes:-                continue-            by_width = defaultdict(list)-            for r in net.routes:-                pts = self._waypoints(net, r)-                if len(pts) < 2:-                    continue-                if getattr(r, "kelvin", False):-                    entries.append({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": True})-                    continue-                by_width[r.width].append(pts)-            stubs = []-            for (cidx, term) in sorted(net.used_escapes):-                cl = net.clusters[cidx]-                stubs.append([cl.primary.key, list(g.to_xy(term[1], term[2]))])-            if stubs:-                entries.append({"net": net.name, "width": RULES["stub"], "viaSize": net.via, "viaDrill": net.drill, "paths": stubs})-            for w in sorted(by_width, reverse=True):-                entries.append({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": by_width[w]})-        return entries--    def report(self):-        nets = {}-        for net in self.nets.values():-            if len(net.clusters) < 2 and not net.plane:-                continue-            nets[net.name] = {-                "clusters": len(net.clusters), "pads": len(net.pads), "routes": len(net.routes),-                "widths": sorted({r.width for r in net.routes}, reverse=True), "plane": net.plane,-                "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order,-                "mstMm": round(net.mst_len, 2),-            }-        return {"rules": RULES, "nets": nets, "stats": dict(self.stats),-                "unrouted": [(n, f) for n in nets for f in nets[n]["failed"]]}---def main(argv=None):-    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])-    ap.add_argument("board")-    ap.add_argument("--out", help="plan JSON (default: <board dir>/fable-esc-routing-plan.json)")-    ap.add_argument("--report", help="router report JSON")-    ap.add_argument("--nets", help="comma separated subset of nets")-    ap.add_argument("--no-planes", action="store_true")-    ap.add_argument("--passes", type=int, default=3, help="full passes; nets that failed a pass are routed first in the next")-    ap.add_argument("--rip-per-net", type=int, default=8); ap.add_argument("--rip-global", type=int, default=400)-    ap.add_argument("--config", help="JSON overriding the board rules: wide, mid, planes, kelvin, via sizes, track, clearance")-    ap.add_argument("--verbose", action="store_true")-    a = ap.parse_args(argv)-    if a.config:-        cfg = json.load(open(a.config, encoding="utf-8"))-        for k in ("wide", "mid", "planes", "kelvin"):-            if k in cfg:-                RULES[k] = cfg[k]-        for k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "via_search_mm"):-            if k in cfg:-                RULES[k] = float(cfg[k])-    board = Board(a.board)-    r = Router(board, a.verbose)-    only = set(a.nets.split(",")) if a.nets else None-    best = None-    priority = None-    for k in range(1, a.passes + 1):-        if k > 1:-            r.reset()-        log("=== pass %d of %d ===" % (k, a.passes))-        failed = r.run(only=only, planes=not a.no_planes, priority=priority, rip_per_net=a.rip_per_net, rip_global=a.rip_global)-        names = sorted({n.name for n in r.nets.values() if n.failed})-        log("=== pass %d: %d failed connections in %s" % (k, failed, names))-        if best is None or failed < best[0]:-            best = (failed, r.snapshot(), k)-        if failed == 0:-            break-        priority = names-    if best is not None:-        r.restore(best[1])-        log("keeping pass %d (%d failed connections)" % (best[2], best[0]))-    entries = r.plan()-    rep = r.report()-    out = a.out or os.path.join(os.path.dirname(os.path.abspath(a.board)), "fable-esc-routing-plan.json")-    with open(out, "w", encoding="utf-8") as f:-        json.dump({"engine": "Claude Fable 5.1 grid maze router (ai_router.py)", "board": os.path.basename(a.board),-                   "rules": {k: v for k, v in RULES.items() if k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "planes", "wide", "mid")},-                   "nets": entries}, f, indent=1)-    log("plan: %s (%d entries, %d nets)" % (out, len(entries), len({e["net"] for e in entries})))-    if rep["unrouted"]:-        log("UNROUTED: %s" % rep["unrouted"])-    if a.report:-        with open(a.report, "w", encoding="utf-8") as f:-            json.dump(rep, f, indent=1)-    return 0 if not rep["unrouted"] else 3---if __name__ == "__main__":-    sys.exit(main())
reference/python/runlog.pydeleted−94
@@ -1,94 +0,0 @@-"""The run manifest: one JSON file per run that carries the clock, the plan, the decisions,-the stage outcomes, the capture markers and the final summary. Every command reads and-writes it, so the comparison between engines is what the file says, not what anyone remembers."""-import json-import os-import time-from pathlib import Path--STAGES = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"]---def now():-    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())---def parse(ts):-    return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone---class Run:-    def __init__(self, path):-        self.dir = Path(path)-        self.file = self.dir / "run.json"-        self.data = json.load(open(self.file, encoding="utf-8")) if self.file.is_file() else None--    def exists(self):-        return self.data is not None--    def save(self):-        self.dir.mkdir(parents=True, exist_ok=True)-        tmp = self.file.with_suffix(".tmp")-        json.dump(self.data, open(tmp, "w", encoding="utf-8"), indent=1)-        os.replace(tmp, self.file)--    @staticmethod-    def create(path, board, spec, engine, prompt_time, ai_thread, target=None, remote_board=None):-        r = Run(path)-        r.data = {-            "version": 1, "engine": engine, "aiThread": ai_thread, "board": str(board), "spec": spec,-            "target": target, "remoteBoard": remote_board,-            "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": None},-            "plan": None, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [str(board)],-            "outcomes": {}, "summary": None,-        }-        r.save()-        return r--    # -- stages ----------------------------------------------------------------------    def stage_start(self, name, engine=None, note=None):-        st = self.data["stages"].setdefault(name, {"runs": []})-        st["runs"].append({"start": now(), "end": None, "engine": engine or self.engine_for(name), "note": note, "result": None})-        self.mark("stage-start:" + name)-        self.save()--    def stage_end(self, name, result=None, note=None):-        st = self.data["stages"].setdefault(name, {"runs": []})-        if not st["runs"] or st["runs"][-1]["end"] is not None:-            st["runs"].append({"start": now(), "end": None, "engine": self.engine_for(name), "note": None, "result": None})-        st["runs"][-1]["end"] = now()-        if result is not None:-            st["runs"][-1]["result"] = result-        if note:-            st["runs"][-1]["note"] = note-        self.mark("stage-end:" + name)-        self.save()--    def engine_for(self, name):-        return self.data["decisions"].get(name) or ((self.data.get("plan") or {}).get("stages", {}).get(name, {}) or {}).get("default", "binary")--    def mark(self, label):-        self.data["markers"].append({"t": now(), "label": label})--    def current_board(self):-        return self.data["boards"][-1]--    def add_board(self, path):-        self.data["boards"].append(str(path))-        self.save()--    # -- time ------------------------------------------------------------------------    def elapsed_minutes(self):-        t0 = parse(self.data["clock"]["promptTime"])-        t1 = parse(self.data["clock"]["finishTime"]) if self.data["clock"]["finishTime"] else time.time()-        return round((t1 - t0) / 60.0, 1)--    def stage_minutes(self):-        out = {}-        for name, st in self.data["stages"].items():-            tot = 0.0-            for r in st["runs"]:-                if r["end"]:-                    tot += parse(r["end"]) - parse(r["start"])-            out[name] = round(tot / 60.0, 1)-        return out