Files Branches

Comparing master ← feature/trace-routing

Changes on feature/trace-routing that are not yet on master (three-dot, from the merge base).

6 files changed, 964 insertions(+), 29 deletions(-)
SKILL.md+37
@@ -146,6 +146,41 @@ Unlabeled nets are auto-named (`Net-(R1-Pad2)`, `unconnected-(C1-Pad1)`); single nets are usually a missed wire but can be intentional — `kicad_run_erc` is the authoritative check and knows about no-connect flags. +## Drawing copper — trace by trace, or a whole path++**The AI is the router.** Nothing here searches for a path or dodges other nets:+`kicad_board_pads` hands you pad centres in BOARD coordinates with their nets,+*you* pick the waypoints, and these verbs lay the copper deterministically by+splicing s-expressions into the `.kicad_pcb` — the same proven approach as+`kicad_place_footprint`, no GUI and no autorouter.++| CLI form | Purpose | Key args |+|---|---|---|+| `kicad_board_pads` | Read-only. Every pad in board mm with its net, plus copper layers, the board's own track width, and `netsNeedingCopper` | `filePath`, optional `net` / `reference` |+| `kicad_add_track` | ONE segment. Call it in a loop when the user should watch the board fill in trace by trace | `filePath`, `start`, `end`, `net`, `layer`, `width` |+| `kicad_add_via` | One via, so a route can change layer | `filePath`, `at`, `net`, `layers`, `size`, `drill` |+| `kicad_route` | A whole path for one net in a single write, vias included | `filePath`, `net`, `points` (or `from`/`to`), `layer`, `width` |++Points are pad names (`"R1.2"`), `[x, y]`, or `{"x":..,"y":..,"layer":".."}` —+a waypoint whose `layer` differs drops a via there and continues on the new+layer. Coordinates are millimetres, **+y is DOWN**. Omit `net` and it is+inferred from the pads at the ends; a pad on a different net is refused as the+short it would be. Width defaults to the width this board already uses.++Four things worth knowing once:++- **Net numbers are gone in KiCad 10.** A board written by KiCad 10 (format+  20260206) has no net table at all and references every net by NAME. These+  verbs read both dialects and write back in whichever the board speaks, so+  address nets by name and they work either way.+- **A board open in pcbnew is refreshed for you** via File > Revert (`reload`,+  default `"auto"`), which is what makes a live trace-by-trace demo work. Check+  `reloaded` — when it is `false` the copper is in the file but the canvas is+  stale, **and KiCad's next save will overwrite it**.+- **Nothing checks clearance.** `kicad_run_drc` is the authority; run it when a+  net is done and re-route what it flags.+- Every write leaves a `.adom-bak` beside the board (`backup:false` to skip).+ ## Exports, linting and settings  | CLI form | Purpose | Key args |@@ -165,6 +200,8 @@ authoritative check and knows about no-connect flags. | `kicad_get_settings` | Read the bridge's user-visible settings (overlay badges, ...) | — | | `kicad_set_settings` | Change bridge settings; applies immediately to live windows | settings keys | | `kicad_place_footprint` | Deterministically place a footprint into a board preview | `fileName`, `fileContent`, `footprintName`, `x`, `y`, `open` |+| `kicad_board_pads` | Pads in board coordinates with their nets — the input for routing | `filePath` |+| `kicad_add_track` / `kicad_add_via` / `kicad_route` | Draw copper (see "Drawing copper" above) | `filePath`, `net`, points | | `kicad_list_footprints` | List footprints in a library | `libraryName` | | `kicad_make_part_project` | Generate a small real project (pro/sch/pcb) AROUND an installed Adom-library part | part name | | `kicad_install_library_bundle` | Install a wiki Cloud Library bundle zip (symbols + footprints + 3D models) in ONE call | `zipPath`, optional `libraryName`, `capture`, `closeEditor` |
bridge.json+4
@@ -41,6 +41,10 @@     "kicad_install_footprint",     "kicad_install_plugin",     "kicad_place_footprint",+    "kicad_board_pads",+    "kicad_add_track",+    "kicad_add_via",+    "kicad_route",     "kicad_run_drc",     "kicad_run_erc",     "kicad_lint_board",
handlers/route.pyadded+822
@@ -0,0 +1,822 @@+"""Handlers for the copper-drawing verbs: add_track, add_via, route, board_pads.++DETERMINISTIC routing -- the same s-expression approach `place_footprint` proved,+pointed at the user's REAL board instead of a preview. A track is+`(segment (start ..) (end ..) (width ..) (layer ..) (net ..) (uuid ..))` spliced+in before the board's final paren; a via is `(via (at ..) (size ..) (drill ..)+(layers ..) (net ..) (uuid ..))`. No GUI, no protobuf, no autorouter.++The AI is the router. `board_pads` hands it pad centres in BOARD coordinates+with their nets; it picks the path; `add_track` (one segment at a time, for a+demo you can watch) or `route` (a whole polyline in one write) lays the copper.++Coordinates are millimetres in KiCad's board frame: +x right, **+y DOWN**.++Board open in pcbnew? The file write does not reach the running editor by+itself, and KiCad's next save would clobber it. These verbs detect that window+and refresh it with File > Revert (`reload`, default "auto"), then say plainly+in `reloaded` whether that worked.+"""++from __future__ import annotations++import math+import os+import shutil+import sys+import time+import uuid+from collections import Counter+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))+from parsers.pcb import parse_pcb_text  # noqa: E402++IS_WINDOWS = sys.platform == "win32"+_callbacks = []  # keep WNDENUMPROC objects alive across the EnumChildWindows call++DEFAULT_WIDTH = 0.2+DEFAULT_VIA_SIZE = 0.6+DEFAULT_VIA_DRILL = 0.3+DEFAULT_LAYER = "F.Cu"+++# ── numbers / geometry ───────────────────────────────────────────────++def _num(v) -> str:+    """KiCad-style trimmed float ('1.5', not '1.500000')."""+    s = f"{float(v):.6f}".rstrip("0").rstrip(".")+    return "0" if s in ("", "-", "-0") else s+++def _dist(a, b) -> float:+    return math.hypot(b[0] - a[0], b[1] - a[1])+++# ── board loading ────────────────────────────────────────────────────++def _load(args: dict):+    """(path, text, board, err). `err` is a ready-to-return dict on failure."""+    raw = args.get("filePath") or args.get("boardPath") or ""+    if not raw:+        return None, "", None, {+            "success": False, "error": "No filePath specified", "errorCode": "missing_arg",+            "_hint": "Pass the absolute path to a .kicad_pcb, e.g. "+                     '{"filePath":"C:/designs/foo/foo.kicad_pcb"}. '+                     "Call kicad_board_pads on it first to see pads, nets and copper layers."}+    path = Path(raw)+    if path.suffix.lower() != ".kicad_pcb":+        return None, "", None, {+            "success": False, "error": f"not a board file: {path.name}", "errorCode": "not_a_board",+            "_hint": "These verbs edit a .kicad_pcb. For a schematic there is nothing to route."}+    if not path.exists():+        return None, "", None, {+            "success": False, "error": f"board not found: {path}", "errorCode": "board_not_found",+            "_hint": "Absolute path, no %VAR% expansion. kicad_place_footprint can make a scratch "+                     "board to route on if you have no project yet."}+    try:+        text = path.read_text(encoding="utf-8")+    except OSError as e:+        return None, "", None, {"success": False, "error": f"could not read board: {e}",+                                "errorCode": "board_unreadable", "_hint": "Is the file locked by another process?"}+    if not text.lstrip().startswith("(kicad_pcb"):+        return None, "", None, {+            "success": False, "error": "file does not start with '(kicad_pcb'",+            "errorCode": "not_a_board",+            "_hint": "A legacy .brd or a truncated file. kicad_format_upgrade converts old formats."}+    try:+        board = parse_pcb_text(text, path)+    except Exception as e:  # pylint: disable=broad-except+        return None, "", None, {+            "success": False, "error": f"could not parse board: {e}", "errorCode": "board_unparsable",+            "_hint": "Our reader is not KiCad's parser. kicad_lint_board gets KiCad's own verdict."}+    return path, text, board, None+++def _copper_layers(board: dict) -> list[str]:+    return [l.get("name", "") for l in board.get("layers", []) if l.get("name", "").endswith(".Cu")]+++# ── nets ─────────────────────────────────────────────────────────────++def _net_key(board: dict, number, name):+    """The identity of a net on THIS board: its number pre-KiCad-10, else its name."""+    return number if board.get("net_format") == "number" else (name or "")+++def _resolve_net(board: dict, spec) -> tuple[dict | None, dict | None]:+    """Resolve a net name or number to a reference this board can carry.++    Never invents one: a track on a net the board does not know is copper KiCad+    will not connect to anything.+    """+    nets = board.get("nets", [])+    fmt = board.get("net_format", "number")+    names = [n.get("name", "") for n in nets]++    if isinstance(spec, bool):+        spec = None+    is_num = isinstance(spec, (int, float)) or (+        isinstance(spec, str) and spec.strip().lstrip("-").isdigit())++    if is_num and fmt == "number":+        num = int(spec)+        for n in nets:+            if n.get("number") == num:+                return {"format": fmt, "number": num, "name": n.get("name", ""),+                        "key": num}, None+        return None, {"success": False, "error": f"net number {num} is not declared on this board",+                      "errorCode": "unknown_net",+                      "nets": [{"number": n.get("number"), "name": n.get("name", "")} for n in nets][:60],+                      "_hint": "Track nets must already exist in the board's (net N \"name\") table -- "+                               "they come from the schematic via the netlist. Pass a name instead, "+                               "or 0 for deliberately unconnected copper."}++    if is_num and fmt == "name":+        if int(spec) == 0:+            return {"format": fmt, "number": None, "name": "", "key": ""}, None+        return None, {"success": False, "error": f"this board has no net numbers (KiCad 10 format)",+                      "errorCode": "unknown_net", "nets": names[:60],+                      "_hint": "KiCad 10 dropped net numbers from the board file -- every net is "+                               "referenced by NAME. Pass \"net\":\"GND\"; kicad_board_pads lists them."}++    name = "" if spec is None else str(spec)+    for n in nets:+        if n.get("name", "") == name:+            return {"format": fmt, "number": n.get("number"), "name": name,+                    "key": _net_key(board, n.get("number"), name)}, None+    for n in nets:+        if n.get("name", "").lower() == name.lower():+            real = n.get("name", "")+            return {"format": fmt, "number": n.get("number"), "name": real,+                    "key": _net_key(board, n.get("number"), real)}, None++    low = name.lower()+    return None, {"success": False, "error": f"no net named '{name}' on this board",+                  "errorCode": "unknown_net",+                  "candidates": [n for n in names if low in n.lower()][:20],+                  "nets": names[:60],+                  "_hint": "Net names are exact and come from the schematic (KiCad auto-names "+                           "unlabelled ones 'Net-(R1-Pad2)'). kicad_board_pads lists every net "+                           "with the pads on it."}+++def _net_sexpr(net: dict) -> str:+    """The (net ...) line in the dialect this board speaks -- or nothing at all+    for deliberately unconnected copper on a KiCad 10 board, which has no net 0+    to name."""+    if net["format"] == "number":+        return f'\t\t(net {int(net["number"] or 0)})\n'+    if not net["name"]:+        return ""+    return f'\t\t(net "{net["name"]}")\n'+++def _default_width(board: dict, net_key=None) -> float:+    """Match the board's own copper: this net's width, else the commonest width."""+    on_net = [s["width"] for s in board.get("segments", [])+              if s.get("width") and (net_key is None+                                     or _net_key(board, s.get("net"), s.get("net_name")) == net_key)]+    if on_net:+        return Counter(on_net).most_common(1)[0][0]+    any_w = [s["width"] for s in board.get("segments", []) if s.get("width")]+    if any_w:+        return Counter(any_w).most_common(1)[0][0]+    return DEFAULT_WIDTH+++# ── pads in board coordinates ────────────────────────────────────────++def _pads(board: dict) -> list[dict]:+    """Every pad, transformed from footprint-local into BOARD coordinates.++    KiCad rotates a footprint's pads by the footprint orientation in a y-DOWN+    frame (trigo.cpp RotatePoint): x' = x·cos+y·sin, y' = -x·sin+y·cos.+    """+    out = []+    for fp in board.get("footprints", []):+        pos = fp.get("position") or {}+        fx, fy = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))+        ang = math.radians(float(pos.get("rotation", 0.0) or 0.0))+        cos_a, sin_a = math.cos(ang), math.sin(ang)+        ref = fp.get("reference", "") or ""+        for pad in fp.get("pads", []):+            if "x" not in pad or "y" not in pad:+                continue+            px, py = float(pad["x"]), float(pad["y"])+            out.append({+                "ref": ref,+                "pad": pad.get("number", ""),+                "name": f"{ref}.{pad.get('number', '')}",+                "x": round(fx + px * cos_a + py * sin_a, 6),+                "y": round(fy - px * sin_a + py * cos_a, 6),+                "net": pad.get("net_number"),+                "netName": pad.get("net_name", ""),+                "netKey": _net_key(board, pad.get("net_number"), pad.get("net_name")),+                "type": pad.get("type", ""),+                "layers": pad.get("layers", []),+                "side": fp.get("layer", ""),+                "footprint": fp.get("footprint", ""),+            })+    return out+++def _pad_reaches(pad: dict | None, layer: str) -> bool:+    """Is this pad actually on that copper layer?++    An SMD pad lives on ONE side; a track that ends on it from the other side+    looks connected on screen and is a dangling track to DRC. Through-hole pads+    carry the "*.Cu" wildcard and are reachable from every layer.+    """+    if not pad:+        return True+    layers = pad.get("layers") or []+    if not layers:+        return True  # nothing to judge it by; let DRC have the last word+    for l in layers:+        if l == layer or l == "*.Cu" or (l.endswith("*") and layer.startswith(l[:-1])):+            return True+    return not any(l.endswith(".Cu") or l == "*.Cu" for l in layers)+++def _pad_net(board: dict, pad: dict | None) -> dict | None:+    """The net a pad sits on, as a reference the writers can emit -- or None for+    a pad with no net (a mounting hole, a fiducial)."""+    if not pad or pad.get("netKey") in (None, 0, ""):+        return None+    return {"format": board.get("net_format", "number"), "number": pad.get("net"),+            "name": pad.get("netName") or "", "key": pad.get("netKey")}+++def _find_pad(pads: list[dict], spec: str) -> tuple[dict | None, dict | None]:+    """'R1.2' / 'R1-2' / 'R1 2' -> that pad."""+    key = str(spec).strip().replace("-", ".").replace(" ", ".").upper()+    for p in pads:+        if p["name"].upper() == key:+            return p, None+    ref = key.split(".")[0]+    near = [p["name"] for p in pads if p["ref"].upper() == ref]+    return None, {"success": False, "error": f"no pad '{spec}' on this board",+                  "errorCode": "unknown_pad",+                  "candidates": near[:20] or [p["name"] for p in pads][:20],+                  "_hint": "Pads are addressed 'REF.PAD' (e.g. 'U1.7'). "+                           "kicad_board_pads lists every pad with its board coordinates."}+++def _resolve_point(spec, pads: list[dict]) -> tuple[tuple | None, dict | None, dict | None]:+    """(x, y), the pad it came from (or None), error. Accepts [x,y], {x,y},+    'REF.PAD' or {"pad":"REF.PAD"}."""+    if isinstance(spec, str):+        pad, err = _find_pad(pads, spec)+        if err:+            return None, None, err+        return (pad["x"], pad["y"]), pad, None+    if isinstance(spec, dict):+        if spec.get("pad"):+            pad, err = _find_pad(pads, spec["pad"])+            if err:+                return None, None, err+            return (pad["x"], pad["y"]), pad, None+        if "x" in spec and "y" in spec:+            try:+                return (float(spec["x"]), float(spec["y"])), None, None+            except (TypeError, ValueError):+                pass+    if isinstance(spec, (list, tuple)) and len(spec) >= 2:+        try:+            return (float(spec[0]), float(spec[1])), None, None+        except (TypeError, ValueError):+            pass+    return None, None, {"success": False, "error": f"could not read a point from {spec!r}",+                        "errorCode": "bad_point",+                        "_hint": 'A point is [x,y] in mm, {"x":..,"y":..}, or a pad name like "R1.2". '+                                 "+y is DOWN in KiCad board coordinates."}+++# ── s-expression emission ────────────────────────────────────────────++def _segment_sexpr(a, b, width, layer, net: dict) -> str:+    return ('\t(segment\n'+            f'\t\t(start {_num(a[0])} {_num(a[1])})\n'+            f'\t\t(end {_num(b[0])} {_num(b[1])})\n'+            f'\t\t(width {_num(width)})\n'+            f'\t\t(layer "{layer}")\n'+            + _net_sexpr(net) ++            f'\t\t(uuid "{uuid.uuid4()}")\n'+            '\t)')+++def _via_sexpr(at, size, drill, layers, net: dict) -> str:+    return ('\t(via\n'+            f'\t\t(at {_num(at[0])} {_num(at[1])})\n'+            f'\t\t(size {_num(size)})\n'+            f'\t\t(drill {_num(drill)})\n'+            f'\t\t(layers "{layers[0]}" "{layers[1]}")\n'+            + _net_sexpr(net) ++            f'\t\t(uuid "{uuid.uuid4()}")\n'+            '\t)')+++def _splice(text: str, blocks: list[str]) -> str:+    """Insert board items before the closing paren (where KiCad keeps copper)."""+    idx = text.rstrip().rfind(")")+    return text[:idx].rstrip() + "\n" + "\n".join(blocks) + "\n)\n"+++def _write(path: Path, new_text: str, backup: bool) -> tuple[str | None, dict | None]:+    """Atomic replace, with a one-deep sidecar backup. Returns (backupPath, err)."""+    bak = None+    try:+        if backup:+            bak = str(path) + ".adom-bak"+            shutil.copy2(path, bak)+        tmp = path.with_suffix(path.suffix + ".adom-tmp")+        tmp.write_text(new_text, encoding="utf-8")+        os.replace(tmp, path)+    except OSError as e:+        return None, {"success": False, "error": f"could not write board: {e}",+                      "errorCode": "board_unwritable",+                      "_hint": "Is the board open and locked, or the folder read-only? "+                               "Nothing was changed."}+    return bak, None+++# ── the running pcbnew ───────────────────────────────────────────────++def _lock_file(path: Path) -> Path | None:+    """KiCad's own open-file marker, `~<name>.kicad_pcb.lck` beside the board.++    It is the one signal that works with no window list at all, and it is what+    tells us a write is about to race a live editor.+    """+    lck = path.parent / f"~{path.name}.lck"+    return lck if lck.exists() else None+++def _editor_hwnd(path: Path) -> int | None:+    """The PCB Editor window showing THIS board, if one is up."""+    try:+        from handlers import kicad_windows+        for row in kicad_windows.find(fresh=True):+            title = (row.get("title") or "")+            if path.stem.lower() in title.lower() and "pcb editor" in title.lower():+                return int(row.get("hwnd") or 0) or None+    except Exception:  # pylint: disable=broad-except+        pass+    return None+++def _reload_editor(hwnd: int) -> tuple[bool, str]:+    """File > Revert on the live editor, so the user SEES the new copper.++    Menu command id straight off the Win32 menu bar and PostMessage'd -- no+    foreground, no cursor (the rule in kicad-bridge-dev). The revert asks for+    confirmation; that dialog is answered through UIA, also without focus.+    """+    try:+        from handlers import win_menu+    except Exception as e:  # pylint: disable=broad-except+        return False, f"win_menu unavailable: {e}"+    cmd, why = win_menu.find_menu_command(hwnd, "revert")+    if not cmd:+        return False, f"no File > Revert menu item ({why})"+    if not win_menu.invoke_menu_command(hwnd, cmd):+        return False, "PostMessage(WM_COMMAND) failed"+    return _confirm_revert(hwnd)+++def _click_dialog_button(dialog_hwnd: int, labels: tuple) -> str | None:+    """Press a named button in a dialog with PostMessage(BM_CLICK) -- no focus.++    wx's buttons answer UIA's `find` but expose no InvokePattern ("not_invokable",+    measured on KiCad 10's revert Confirmation), so the a11y route dead-ends. The+    button is a real child window, though, so class-walk to it and post the click+    to that ONE window: the same trick `_gl_canvas_rect` uses to find the canvas.+    """+    if not IS_WINDOWS:+        return None+    import ctypes+    from ctypes import wintypes+    user32 = ctypes.windll.user32+    BM_CLICK = 0x00F5+    wanted = tuple(l.lower() for l in labels)+    found = []++    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)++    def cb(child, _l):+        cls = ctypes.create_unicode_buffer(256)+        user32.GetClassNameW(child, cls, 256)+        if cls.value.lower().endswith("button"):+            txt = ctypes.create_unicode_buffer(256)+            user32.GetWindowTextW(child, txt, 256)+            # strip wx's '&' mnemonic markers before matching ("&Yes" is "Yes")+            label = (txt.value or "").replace("&", "").strip()+            if label.lower() in wanted:+                found.append((child, label))+                return False+        return True++    c = WNDENUMPROC(cb)+    _callbacks.append(c)+    try:+        user32.EnumChildWindows(dialog_hwnd, c, 0)+    finally:+        _callbacks.remove(c)+    if not found:+        return None+    child, label = found[0]+    user32.PostMessageW(child, BM_CLICK, 0, 0)+    return label+++def _confirm_revert(editor_hwnd: int) -> tuple[bool, str]:+    """Answer KiCad's 'Revert ... to last version saved?' with Yes.++    Leaving this dialog up would wedge the editor behind a modal, so a dialog we+    cannot answer is reported as a failure loudly rather than left on screen.+    """+    try:+        from handlers import kicad_windows, uia+    except Exception as e:  # pylint: disable=broad-except+        return False, f"window helpers unavailable: {e}"+    deadline = time.monotonic() + 6.0+    while time.monotonic() < deadline:+        time.sleep(0.25)+        for row in kicad_windows.find(all_windows=True, fresh=True):+            hwnd = int(row.get("hwnd") or 0)+            title = (row.get("title") or "").lower()+            if not hwnd or hwnd == editor_hwnd:+                continue+            if "revert" not in title and "confirm" not in title:+                continue+            clicked = _click_dialog_button(hwnd, ("Yes", "Revert", "OK"))+            if clicked:+                return True, f"reverted (confirmed '{clicked}')"+            for label in ("Yes", "Revert", "OK"):  # a11y fallback+                if uia.uia_invoke(hwnd, name=label).get("ok"):+                    return True, f"reverted (confirmed '{label}' via UIA)"+            return False, (f"confirmation dialog '{row.get('title')}' has no button we could press "+                           "-- it is still on screen and the editor is blocked behind it")+    # No dialog is a legitimate outcome: KiCad only asks when the board is dirty.+    return True, "reverted (no confirmation needed)"+++def _after_write(path: Path, args: dict, result: dict) -> dict:+    """Shared tail: refresh the live editor if there is one, and say so."""+    hwnd = _editor_hwnd(path)+    lock = _lock_file(path)+    result["boardOpenInKicad"] = bool(hwnd or lock)+    mode = args.get("reload", "auto")+    if not hwnd:+        if lock:+            result["reloaded"] = False+            result["_hint"] = (result.get("_hint", "") + f" KiCad still holds a lock on this board "+                               f"({lock.name}) but no PCB Editor window was found to refresh -- the "+                               "editor may be on another desktop or mid-launch. The user must hit "+                               "File > Revert to see the copper, and KiCad's next save would "+                               "overwrite it.").strip()+        return result+    if mode is False or mode == "never":+        result["reloaded"] = False+        result["_hint"] = (result.get("_hint", "") + " The board is OPEN in the PCB Editor and you "+                           "passed reload:false -- the canvas still shows the old copper, and KiCad's "+                           "next Ctrl+S will overwrite what was just written.").strip()+        return result+    ok, why = _reload_editor(hwnd)+    result["reloaded"] = ok+    result["reloadDetail"] = why+    if not ok:+        result["_hint"] = (result.get("_hint", "") + f" The copper IS in the file, but the open PCB "+                           f"Editor could not be refreshed ({why}) -- it still shows the old board, and "+                           "KiCad's next save will overwrite the new copper. Ask the user to hit "+                           "File > Revert, or close the board before routing.").strip()+    return result+++# ── verbs ────────────────────────────────────────────────────────────++def handle_board_pads(kicad_info: dict, args: dict) -> dict:+    """Read-only routing intelligence: pads in board coordinates, nets, layers."""+    path, _text, board, err = _load(args)+    if err:+        return err++    pads = _pads(board)+    net_filter = args.get("net")+    if net_filter not in (None, ""):+        net, nerr = _resolve_net(board, net_filter)+        if nerr:+            return nerr+        pads = [p for p in pads if p.get("netKey") == net["key"]]+    ref_filter = args.get("reference")+    if ref_filter:+        pads = [p for p in pads if p["ref"].upper() == str(ref_filter).upper()]++    by_net: dict[str, list[str]] = {}+    for p in _pads(board):+        if p.get("netKey") not in (None, 0, ""):+            by_net.setdefault(p.get("netName") or f"net{p['net']}", []).append(p["name"])++    segs = board.get("segments", [])+    routed = {_net_key(board, s.get("net"), s.get("net_name")) for s in segs}+    nets = [{"number": n.get("number"), "name": n.get("name", ""),+             "pads": len(by_net.get(n.get("name", ""), [])),+             "routed": _net_key(board, n.get("number"), n.get("name")) in routed}+            for n in board.get("nets", []) if n.get("name")]+    needs_copper = sorted(n["name"] for n in nets if n["pads"] > 1 and not n["routed"])++    return {+        "success": True,+        "boardPath": str(path),+        "pads": pads,+        "padCount": len(pads),+        "nets": nets,+        "copperLayers": _copper_layers(board),+        "netFormat": board.get("net_format"),+        "defaultWidth": _default_width(board),+        "existingTracks": len(segs),+        "existingVias": len(board.get("vias", [])),+        "boardDimensions": board.get("dimensions"),+        "netsNeedingCopper": needs_copper[:60],+        "output": f"{len(pads)} pads, {len(nets)} nets, {len(segs)} tracks on "+                  f"{len(_copper_layers(board))} copper layers",+        "_hint": "Pad x/y are BOARD millimetres (+y is DOWN) with footprint rotation applied -- "+                 "feed them straight to kicad_route or kicad_add_track. Route a net by name and "+                 "pick your own waypoints; there is no autorouter behind this. Check `routed` to "+                 "see which nets still need copper (`netsNeedingCopper` is that list), and keep "+                 "tracks off other nets' pads: "+                 "kicad_run_drc is the authority on clearance.",+    }+++def handle_add_track(kicad_info: dict, args: dict) -> dict:+    """ONE copper segment. The verb to call in a loop when the user should watch+    the board fill in trace by trace."""+    path, text, board, err = _load(args)+    if err:+        return err+    pads = _pads(board)++    start_spec = args.get("start", args.get("from"))+    end_spec = args.get("end", args.get("to"))+    if start_spec is None or end_spec is None:+        return {"success": False, "error": "add_track needs both start and end",+                "errorCode": "missing_arg",+                "_hint": 'kicad_add_track {"filePath":"...","start":"R1.2","end":[120.5,90],'+                         '"net":"GND","layer":"F.Cu"}. Points are pad names or [x,y] in mm.'}+    a, pad_a, err = _resolve_point(start_spec, pads)+    if err:+        return err+    b, pad_b, err = _resolve_point(end_spec, pads)+    if err:+        return err++    net_spec = args.get("net")+    if net_spec in (None, ""):+        net = _pad_net(board, pad_a) or _pad_net(board, pad_b)+        if net is None:+            return {"success": False, "error": "no net given and neither endpoint is a pad on a net",+                    "errorCode": "missing_net",+                    "_hint": 'Pass "net":"GND" (a name from kicad_board_pads), or "net":0 for '+                             "deliberately unconnected copper. Copper on the wrong net is a DRC error."}+    else:+        net, nerr = _resolve_net(board, net_spec)+        if nerr:+            return nerr++    for pad in (pad_a, pad_b):+        pad_net = _pad_net(board, pad)+        if pad_net and pad_net["key"] != net["key"]:+            return {"success": False,+                    "error": f"pad {pad['name']} is on net '{pad.get('netName')}', "+                             f"not the net you asked to route ('{net['name']}')",+                    "errorCode": "net_mismatch",+                    "_hint": "Routing a track to a pad on a different net is a short. Check the "+                             "pad's net with kicad_board_pads, or drop the net arg and let the "+                             "endpoints decide it."}++    if _dist(a, b) < 1e-6:+        return {"success": False, "error": "start and end are the same point",+                "errorCode": "zero_length",+                "_hint": "A zero-length track is a DRC error in KiCad. Give the segment a real span, "+                         "or use kicad_add_via for a layer change at one point."}++    layer = args.get("layer") or (pad_a or {}).get("side") or DEFAULT_LAYER+    copper = _copper_layers(board)+    if layer not in copper:+        return {"success": False, "error": f"'{layer}' is not a copper layer on this board",+                "errorCode": "unknown_layer", "copperLayers": copper,+                "_hint": "Tracks only live on copper. This board's copper layers are listed above; "+                         "a 2-layer board has only F.Cu and B.Cu."}+    width = float(args.get("width") or _default_width(board, net["key"]))++    block = _segment_sexpr(a, b, width, layer, net)+    bak, werr = _write(path, _splice(text, [block]), args.get("backup", True) is not False)+    if werr:+        return werr++    warnings = [f"pad {p['name']} is not on {layer} (it is on {', '.join(p.get('layers') or []) or 'no copper'}) "+                f"-- this end of the track is dangling, not connected"+                for p in (pad_a, pad_b) if not _pad_reaches(p, layer)]++    net_name = net["name"]+    result = {+        "success": True, "boardPath": str(path), "backupPath": bak, "warnings": warnings,+        "start": list(a), "end": list(b), "layer": layer, "width": width,+        "net": net["number"], "netName": net_name,+        "lengthMm": round(_dist(a, b), 4),+        "tracksOnBoard": len(board.get("segments", [])) + 1,+        "output": f"Track on {net_name or 'no net'}: ({_num(a[0])}, {_num(a[1])}) -> "+                  f"({_num(b[0])}, {_num(b[1])}) on {layer}, {_num(width)} mm wide",+        "_hint": "One segment written. Call again for the next one -- that is the trace-by-trace "+                 "loop. For a whole path in a single write use kicad_route. Nothing here checks "+                 "clearance: run kicad_run_drc when the net is done.",+    }+    return _after_write(path, args, result)+++def handle_add_via(kicad_info: dict, args: dict) -> dict:+    """One via -- how a route changes layer."""+    path, text, board, err = _load(args)+    if err:+        return err+    pads = _pads(board)++    at_spec = args.get("at", args.get("position"))+    if at_spec is None:+        return {"success": False, "error": "add_via needs `at`", "errorCode": "missing_arg",+                "_hint": 'kicad_add_via {"filePath":"...","at":[120,90],"net":"GND"}. '+                         "Place it where the track changes layer."}+    at, pad, err = _resolve_point(at_spec, pads)+    if err:+        return err++    net_spec = args.get("net")+    if net_spec in (None, ""):+        net = _pad_net(board, pad)+        if net is None:+            return {"success": False, "error": "no net given and `at` is not a pad on a net",+                    "errorCode": "missing_net",+                    "_hint": 'Pass "net":"GND". A via must carry the net of the track it joins.'}+    else:+        net, nerr = _resolve_net(board, net_spec)+        if nerr:+            return nerr++    copper = _copper_layers(board)+    layers = args.get("layers") or [copper[0] if copper else "F.Cu",+                                    copper[-1] if copper else "B.Cu"]+    if len(layers) < 2 or any(l not in copper for l in layers[:2]):+        return {"success": False, "error": f"via layers {layers} are not both copper on this board",+                "errorCode": "unknown_layer", "copperLayers": copper,+                "_hint": 'A via spans two copper layers, e.g. "layers":["F.Cu","B.Cu"].'}++    size = float(args.get("size") or DEFAULT_VIA_SIZE)+    drill = float(args.get("drill") or DEFAULT_VIA_DRILL)+    if drill >= size:+        return {"success": False, "error": f"drill {drill} is not smaller than pad size {size}",+                "errorCode": "bad_via_geometry",+                "_hint": "A via's drill must be smaller than its pad, or there is no annular ring."}++    block = _via_sexpr(at, size, drill, layers[:2], net)+    bak, werr = _write(path, _splice(text, [block]), args.get("backup", True) is not False)+    if werr:+        return werr++    result = {+        "success": True, "boardPath": str(path), "backupPath": bak,+        "at": list(at), "size": size, "drill": drill, "layers": layers[:2],+        "net": net["number"], "netName": net["name"],+        "viasOnBoard": len(board.get("vias", [])) + 1,+        "output": f"Via at ({_num(at[0])}, {_num(at[1])}) joining {layers[0]}->{layers[1]} "+                  f"on {net['name'] or 'no net'}",+        "_hint": "The via is copper on its own -- the tracks either side of it still have to be "+                 "drawn (kicad_add_track), or let kicad_route place vias for you by giving a "+                 "waypoint a different layer. Check the size against the fab's rules: "+                 "kicad_list_design_rules.",+    }+    return _after_write(path, args, result)+++def handle_route(kicad_info: dict, args: dict) -> dict:+    """A whole path for one net in ONE write: waypoints in, copper out.++    Points are pad names or coordinates. Give a point a `layer` different from+    the running layer and a via is dropped there automatically.+    """+    path, text, board, err = _load(args)+    if err:+        return err+    pads = _pads(board)++    raw_points = args.get("points") or args.get("path") or []+    if isinstance(raw_points, (str, dict)):+        raw_points = [raw_points]+    raw_points = list(raw_points)+    if args.get("from") is not None:+        raw_points.insert(0, args["from"])+    if args.get("to") is not None:+        raw_points.append(args["to"])+    if len(raw_points) < 2:+        return {"success": False, "error": "a route needs at least two points",+                "errorCode": "missing_arg",+                "_hint": 'kicad_route {"filePath":"...","net":"GND","points":["R1.2",[120,88],'+                         '{"x":130,"y":88,"layer":"B.Cu"},"U1.7"]}. Or just "from"/"to" for a '+                         "straight shot. You choose the waypoints -- this lays copper, it does not "+                         "search for a path."}++    resolved = []+    for i, spec in enumerate(raw_points):+        pt, pad, perr = _resolve_point(spec, pads)+        if perr:+            perr["_hint"] = f"(point {i} of the route) " + perr.get("_hint", "")+            return perr+        layer = spec.get("layer") if isinstance(spec, dict) else None+        resolved.append({"pt": pt, "pad": pad, "layer": layer})++    net_spec = args.get("net")+    if net_spec in (None, ""):+        net = next((n for n in (_pad_net(board, r["pad"]) for r in resolved) if n), None)+        if net is None:+            return {"success": False, "error": "no net given and no waypoint is a pad on a net",+                    "errorCode": "missing_net",+                    "_hint": 'Pass "net":"GND" -- kicad_board_pads lists the names.'}+    else:+        net, nerr = _resolve_net(board, net_spec)+        if nerr:+            return nerr+    for r in resolved:+        pad_net = _pad_net(board, r["pad"])+        if pad_net and pad_net["key"] != net["key"]:+            return {"success": False,+                    "error": f"pad {r['pad']['name']} is on net '{r['pad'].get('netName')}', "+                             f"not the routed net ('{net['name']}')",+                    "errorCode": "net_mismatch",+                    "_hint": "Every pad on a route must share the net, or the copper shorts two "+                             "nets together. Check with kicad_board_pads."}++    copper = _copper_layers(board)+    layer = args.get("layer") or (resolved[0]["layer"] or (resolved[0]["pad"] or {}).get("side")+                                  or DEFAULT_LAYER)+    if layer not in copper:+        return {"success": False, "error": f"'{layer}' is not a copper layer on this board",+                "errorCode": "unknown_layer", "copperLayers": copper,+                "_hint": "Tracks only live on copper; this board's copper layers are listed above."}+    width = float(args.get("width") or _default_width(board, net["key"]))+    via_size = float(args.get("viaSize") or DEFAULT_VIA_SIZE)+    via_drill = float(args.get("viaDrill") or DEFAULT_VIA_DRILL)++    blocks, segments, vias, warnings = [], [], [], []+    total = 0.0+    for i in range(len(resolved) - 1):+        here, nxt = resolved[i], resolved[i + 1]+        want = here["layer"] or layer+        if want not in copper:+            return {"success": False, "error": f"'{want}' (point {i}) is not a copper layer",+                    "errorCode": "unknown_layer", "copperLayers": copper,+                    "_hint": "A waypoint's `layer` switches the route onto that copper layer and "+                             "drops a via there."}+        if want != layer:+            blocks.append(_via_sexpr(here["pt"], via_size, via_drill, [layer, want], net))+            vias.append({"at": list(here["pt"]), "layers": [layer, want]})+            layer = want+        for pad_wp in (here, nxt) if i == len(resolved) - 2 else (here,):+            if not _pad_reaches(pad_wp["pad"], layer):+                warnings.append(+                    f"pad {pad_wp['pad']['name']} is not on {layer} (it is on "+                    f"{', '.join(pad_wp['pad'].get('layers') or []) or 'no copper'}) -- the route "+                    "touches it on the wrong side and will read as dangling")+        if _dist(here["pt"], nxt["pt"]) < 1e-6:+            continue  # duplicate waypoint -- a zero-length track is a DRC error+        blocks.append(_segment_sexpr(here["pt"], nxt["pt"], width, layer, net))+        segments.append({"start": list(here["pt"]), "end": list(nxt["pt"]), "layer": layer})+        total += _dist(here["pt"], nxt["pt"])++    if not blocks:+        return {"success": False, "error": "every waypoint was the same point -- no copper to draw",+                "errorCode": "zero_length",+                "_hint": "Give the route waypoints that actually differ."}++    bak, werr = _write(path, _splice(text, blocks), args.get("backup", True) is not False)+    if werr:+        return werr++    net_name = net["name"]+    result = {+        "success": True, "boardPath": str(path), "backupPath": bak, "warnings": warnings,+        "net": net["number"], "netName": net_name, "width": width,+        "segments": segments, "segmentCount": len(segments),+        "vias": vias, "viaCount": len(vias),+        "lengthMm": round(total, 4),+        "endLayer": layer,+        "tracksOnBoard": len(board.get("segments", [])) + len(segments),+        "output": f"Routed {net_name or 'no net'}: {len(segments)} segments"+                  + (f" and {len(vias)} vias" if vias else "")+                  + f", {round(total, 2)} mm of copper",+        "_hint": "Copper written in one shot. This verb draws exactly the path you gave it -- it "+                 "does not avoid other nets, so run kicad_run_drc afterwards and re-route anything "+                 "it flags. For a demo the user watches build up, call kicad_add_track per segment "+                 "instead.",+    }+    return _after_write(path, args, result)
parsers/pcb.py+77−29
@@ -8,7 +8,24 @@ from __future__ import annotations  from pathlib import Path -from .sexpr import parse_file, find_node, find_nodes, node_value+from .sexpr import parse, parse_file, find_node, find_nodes, node_value+++def _net_ref(node: list | None) -> tuple[int | None, str | None]:+    """Read a (net ...) reference in either board format.++    KiCad <=9 numbers its nets and declares them at the top of the board:+    `(net 3 "GND")` on a pad, `(net 3)` on a segment. KiCad 10 (file format+    20260206) dropped the table and the numbers entirely -- every reference is+    just `(net "GND")`. Both shapes appear in the wild, so read both.+    """+    if not node or len(node) < 2:+        return None, None+    first = node[1]+    if isinstance(first, str) and first.lstrip("-").isdigit():+        name = node[2] if len(node) >= 3 and isinstance(node[2], str) else None+        return int(first), name+    return None, first if isinstance(first, str) else None   def parse_pcb(filepath: str | Path) -> dict:@@ -17,8 +34,46 @@ def parse_pcb(filepath: str | Path) -> dict:     return _extract_pcb(tree, filepath)  +def parse_pcb_text(text: str, filepath: str | Path = "") -> dict:+    """Same as parse_pcb, but for board text already in memory.++    The routing verbs splice new copper into the board TEXT, so they hold the+    string anyway; re-reading the file to inspect it would race their own write.+    """+    return _extract_pcb(parse(text), filepath)++ def _extract_pcb(tree: list, filepath: str | Path) -> dict:     """Extract all relevant data from a parsed PCB tree."""+    data = _extract_pcb_raw(tree, filepath)+    return _resolve_net_format(data)+++def _resolve_net_format(data: dict) -> dict:+    """Say which net dialect the board speaks, and give it a net list either way.++    A KiCad 10 board has no net table to read, so the only inventory of its nets+    is the names its pads and copper mention. Callers that write copper need+    this: the (net ...) they emit has to match the shape of the file.+    """+    numbered = bool(data.get("nets")) or any(+        p.get("net_number") is not None+        for fp in data.get("footprints", []) for p in fp.get("pads", []))+    data["net_format"] = "number" if numbered else "name"+    if not data.get("nets"):+        names, seen = [], set()+        for name in ([p.get("net_name") for fp in data.get("footprints", [])+                      for p in fp.get("pads", [])]+                     + [s.get("net_name") for s in data.get("segments", [])]+                     + [v.get("net_name") for v in data.get("vias", [])]):+            if name and name not in seen:+                seen.add(name)+                names.append(name)+        data["nets"] = [{"number": None, "name": n} for n in names]+    return data+++def _extract_pcb_raw(tree: list, filepath: str | Path) -> dict:     return {         "file": str(filepath),         "version": node_value(find_node(tree, "version")),@@ -85,17 +140,12 @@ def _extract_layers(tree: list) -> list[dict]:   def _extract_nets(tree: list) -> list[dict]:-    """Extract net definitions."""+    """Net definitions from the board's own table (KiCad <=9 only)."""     nets = []     for node in find_nodes(tree, "net"):-        if len(node) >= 3:-            try:-                nets.append({-                    "number": int(node[1]),-                    "name": node[2] if isinstance(node[2], str) else "",-                })-            except (ValueError, TypeError):-                pass+        num, name = _net_ref(node)+        if num is not None:+            nets.append({"number": num, "name": name or ""})     return nets  @@ -192,13 +242,13 @@ def _extract_footprints(tree: list) -> list[dict]:                         pad_info["height"] = float(size_node[2])                     except (ValueError, TypeError):                         pass-                net_node = find_node(pad, "net")-                if net_node and len(net_node) >= 3:-                    try:-                        pad_info["net_number"] = int(net_node[1])-                        pad_info["net_name"] = net_node[2] if isinstance(net_node[2], str) else ""-                    except (ValueError, TypeError):-                        pass+                pad_layers = find_node(pad, "layers")+                if pad_layers:+                    pad_info["layers"] = [l for l in pad_layers[1:] if isinstance(l, str)]+                num, name = _net_ref(find_node(pad, "net"))+                if num is not None or name is not None:+                    pad_info["net_number"] = num+                    pad_info["net_name"] = name or ""                 pad_list.append(pad_info)         fp["pads"] = pad_list @@ -232,12 +282,11 @@ def _extract_segments(tree: list) -> list[dict]:         layer = find_node(node, "layer")         if layer:             seg["layer"] = node_value(layer)-        net = find_node(node, "net")-        if net:-            try:-                seg["net"] = int(node_value(net))-            except (ValueError, TypeError):-                pass+        num, name = _net_ref(find_node(node, "net"))+        if num is not None:+            seg["net"] = num+        if name is not None:+            seg["net_name"] = name         segments.append(seg)     return segments @@ -274,12 +323,11 @@ def _extract_vias(tree: list) -> list[dict]:         layers = find_node(node, "layers")         if layers:             via["layers"] = [l for l in layers[1:] if isinstance(l, str)]-        net = find_node(node, "net")-        if net:-            try:-                via["net"] = int(node_value(net))-            except (ValueError, TypeError):-                pass+        num, name = _net_ref(find_node(node, "net"))+        if num is not None:+            via["net"] = num+        if name is not None:+            via["net_name"] = name         vias.append(via)     return vias 
server.py+23
@@ -44,6 +44,8 @@ from handlers.install_library_bundle import handle_install_library_bundle from handlers.install_symbol import handle_install_symbol from handlers.install_footprint import handle_install_footprint, handle_list_footprints from handlers.place_footprint import handle_place_footprint+from handlers.route import (handle_add_track, handle_add_via, handle_route,+                           handle_board_pads) from handlers.run_drc import handle_run_drc from handlers.fix_keyboard import handle_fix_keyboard from handlers.kicad_ui import handle_screenshot_all, handle_send_key, handle_click@@ -440,6 +442,22 @@ _VERB_CATALOG = {     "install_plugin": {"summary": "Install the reverse-bridge plugin into KiCad (idempotent).", "long": False,         "hint": "Normally auto-runs on your first kicad_* call; call explicitly only to force/repair.",         "related": ["kicad_bridge_status", "kicad_bridge_call"], "pitfalls": ["needs a restart of an already-running KiCad to load the freshly-installed plugin"]},+    "board_pads": {"summary": "Every pad in BOARD coordinates with its net — the input a router needs.", "long": False,+        "hint": "kicad_board_pads {\"filePath\":\"C:/d/x.kicad_pcb\"} (optional net/reference filter). Read-only. x/y are mm with footprint rotation applied, +y DOWN; `netsNeedingCopper` is what is still unrouted.",+        "related": ["kicad_route", "kicad_add_track", "kicad_extract_netlist"],+        "pitfalls": ["pad coordinates are the pad CENTRE — a track to it still has to clear other nets", "KiCad 10 boards have no net numbers; address nets by name"]},+    "add_track": {"summary": "Draw ONE copper segment on a board. Call in a loop to route trace by trace.", "long": False,+        "hint": "kicad_add_track {\"filePath\":\"...\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}. Endpoints are pad names or [x,y] mm. Width defaults to the board's own.",+        "related": ["kicad_route", "kicad_add_via", "kicad_board_pads", "kicad_run_drc"],+        "pitfalls": ["there is no autorouter: YOU choose the path, and nothing checks clearance until kicad_run_drc", "edits the file — a board open in pcbnew is refreshed via File > Revert, and `reloaded:false` means the user's next save wins"]},+    "add_via": {"summary": "Drop one via so a route can change layer.", "long": False,+        "hint": "kicad_add_via {\"filePath\":\"...\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}. Defaults 0.6 mm pad / 0.3 mm drill.",+        "related": ["kicad_add_track", "kicad_route", "kicad_list_design_rules"],+        "pitfalls": ["the via alone connects nothing — the tracks either side still have to be drawn", "check size/drill against the fab's rules"]},+    "route": {"summary": "Lay a whole path for one net in a single write, vias included.", "long": False,+        "hint": "kicad_route {\"filePath\":\"...\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}. A waypoint with a different layer drops a via there.",+        "related": ["kicad_add_track", "kicad_board_pads", "kicad_run_drc"],+        "pitfalls": ["draws exactly the waypoints you give — it does not search for a path or avoid other nets", "for a demo the user watches build up, call kicad_add_track per segment instead"]},     "place_footprint": {"summary": "Deterministically place a footprint into a board preview.", "long": False,         "hint": "Splices a footprint into a board via s-expr (no GUI); good for previews.",         "related": ["kicad_open_board", "kicad_install_footprint"], "pitfalls": ["operates on the file — close the board in KiCad first or the edit races the GUI"]},@@ -909,6 +927,11 @@ COMMAND_HANDLERS = {     "install_symbol": handle_install_symbol,     "install_footprint": handle_install_footprint,     "place_footprint": handle_place_footprint,+    # copper: the AI picks the path, these verbs lay the trace (see handlers/route.py)+    "board_pads": handle_board_pads,+    "add_track": handle_add_track,+    "add_via": handle_add_via,+    "route": handle_route,     "run_drc": handle_run_drc,     "fix_keyboard": handle_fix_keyboard,     "screenshot_all": handle_screenshot_all,
tools/release_files.json+1
@@ -38,6 +38,7 @@     "handlers/plugin_diagnose.py",     "handlers/png_stdlib.py",     "handlers/progress.py",+    "handlers/route.py",     "handlers/run_drc.py",     "handlers/settings.py",     "handlers/show.py",