app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
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",