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.
← Commit history
1.0.3: placement verbs, fixture tool, etiquette restore through ab
3 files changed
+55
SKILL.md+23@@ -277,6 +277,29 @@ A passing subset is not all-verbs acceptance. For optional presentation and recordings, see [demo/routing/README.md](demo/routing/README.md). +## Placement (from 1.0.3)++Three verbs let an AI place footprints on a real board the way the routing verbs let it route one: read the state, move parts live through the KiCad IPC API as native Undo steps, validate. Same requirements as live routing (KiCad 10.0.1+, IPC API server on, the board open in the PCB editor); the read-only calls and a `dryRun` move also answer from the file on disk when no editor has the board (`source: "file"`), so planning can start before KiCad is up.++| Verb | What | Args |+|---|---|---|+| `kicad_placement_state` | Every footprint: `ref`, `value`, `footprint`, `uuid`, `side` (F.Cu or B.Cu), `x`, `y`, `rotation`, `courtyard` box in board mm, `courtyardSource`, `padCount`, `pads[]` (pad and net), `locked`, `insideOutline`; the `outline` box; `nets[]` with `refs` (the footprints each net touches) and `mstLengthMm`; `ratsnest.totalMm`; `revision` | `filePath`, optional `expectedRevision`, `detail` (per-pad nets above 200 footprints), `socketPath` |+| `kicad_move_footprint` | Move one footprint or a batch as ONE Undo step, after the revision, courtyard and outline checks. Returns `moves[]` with before and after poses and courtyards, `checks`, `undoSteps: 1`, the new `revision`, `after[]` read back from the editor and `verified` | `filePath`, `expectedRevision`, `ref`, `x`, `y`, optional `rotation` (degrees), `side` (F.Cu or B.Cu flips), or `refs: [{ref, x, y, rotation?, side?}, ...]` (up to 256); `dryRun`, `allowOverlap`, `allowOutside`, `allowLocked`, `save` |+| `kicad_placement_validate` | `courtyardOverlaps[]` over every same-side pair, `outsideOutline[]`, `parked[]` (wholly to the right of the outline), `withoutCourtyard[]`, `ratsnest.totalMm`, `placed`, and `drc` (KiCad DRC on the live snapshot filtered to `placementViolations`: courtyards_overlap, malformed_courtyard, copper_edge_clearance, silk, hole and pad clearance, with the same truncation flags as `kicad_routing_validate`) | `filePath`, optional `socketPath` |++Refusals, with nothing written: `stale_board` (read state again), `courtyard_overlap` (`offendingRefs` names the parts in the way; `allowOverlap:true` overrides), `outside_outline` (`allowOutside:true` parks a part off the board on purpose), `footprint_locked` (`allowLocked:true`), `unknown_footprint` (with candidates). The courtyard is the F.CrtYd or B.CrtYd box after rotation (exact at multiples of 90 degrees, the enclosing box otherwise); a footprint without one gets its pad extent plus 0.25 mm and `courtyardSource` says so. Boxes on different sides never collide, which is KiCad's own courtyard rule. The outline is the Edge.Cuts bounding box. Coordinates are mm, +y down, rotation in KiCad's degrees.++The AI placement recipe:++1. `kicad_placement_state` for the revision, the outline, every courtyard and `nets[].refs`.+2. Cluster by net: the parts that share the most nets belong together. Read the schematic (`kicad_extract_netlist`) for what each cluster is: the MCU and its crystal, the regulator and its inductor, the gate driver and the half bridges.+3. Place the anchors first (connectors on the edge, the MCU, the power stage), then everything that hangs off them. Put each decoupling capacitor next to the IC pin it serves, on the same side, shortest path from the cap's pad to the pin. Keep the power stage together: driver, FETs, current sense and bulk capacitors in one tight group, away from the MCU's analog pins.+4. Move with `kicad_move_footprint` (`dryRun:true` first when the spot is tight), one part or one cluster per call, `expectedRevision` chained from each response. Every call is one Undo step, so the user can step back through the placement.+5. Watch `ratsnest.totalMm` fall with every response. A move that raises it is usually wrong.+6. Finish with `kicad_placement_validate`: `placed` true, `drc.placementClean` true, `parked` empty. Then route (`kicad_autoroute`, engine ai, then `kicad_route_net`).++The demo fixture for this is [demo/placement/README.md](demo/placement/README.md): the public Adom ESC G431 board (149 footprints, 4 layers) with every footprint parked to the right of the outline and all copper removed, made by `tools/make_placement_fixture.py`, which turns any placed board into the same exercise.+ ## Offline copper editing (from 0.9.340) `kicad_board_pads` reads pads and existing copper from the saved file.
docs/rust-port-plan.md+6@@ -22,6 +22,8 @@ Written 2026-09-11 from a full audit of the 0.9.350 source, the Adom Bridge (ab) | 2026-09-11 | 1.0.0 release candidate built from the exact zip the packager produces (96 verbs, 3.0 MB with the demo audio, no runtime) and gated on ConfRoomROG: 71 pass, 4 fail, 15 skip, `kicad_demo` passing for the first time. The four: three foreground notes whose "before" window was a Chrome window another session was opening and closing on the same box during the run (the two earlier gates today had one and zero such notes), and the runner's own cleanup step, which found two folders held open by KiCad windows still up; the same cleanup ran clean once KiCad was closed. Waiting on John for the insiders flip. | | 2026-09-12 | Shipped. Native 1.0.0 is the insiders tier (John: "flip it"). The release row and zip live on the kicad-bridge page, the insiders manifest names it with `runtime: native`, the skills package is 1.0.137 with the new verbs documented, and ConfRoomROG and arav-rog pulled it through ab's own installer (sha verified) and answer natively; the other insiders boxes follow on their poll. Public stays on Python 0.9.348 until a human promotes. Rollback is the insiders manifest back to 0.9.351. | | 2026-09-12 | Freerouting, on the user's terms. `kicad_freerouting {status|install|uninstall}` and the `freerouting` engine of `kicad_autoroute` shipped in 1.0.1. The bridge writes the Specctra DSN itself, runs Freerouting's own self-contained bundle headless (its Java runtime lives inside that folder; nothing installed on the PC, no PATH, no UAC: the MSI is extracted with an administrative extract into a fresh subfolder of the bridge cache), reads the SES back and lands the copper as one native undo step per net through the IPC API, or into the file with a backup, then a DRC. Install runs as a background job (ab's request budget is shorter than an 88 MB download on a slow link) and status reports its progress. Verified on ConfRoomROG: install 6 s, six-net fixture routed in 2 passes and 3 s, file apply DRC clean, live apply six undo steps in 4.6 s at zero errors and zero unconnected, uninstall freed 147 MB. Two Windows facts learned the hard way: msiexec property values with spaces need the quotes inside the token or msiexec waits forever on its usage box, and the administrative image must go to an empty folder that does not hold the package or msiexec returns 1603. The `kicad-autorouting` skill carries the choice: the AI engine is the recommendation, Freerouting is offered every time and installed only when the user says yes. |+| 2026-09-12 | Placement verbs, the same way the routing verbs work. `kicad_placement_state` (every footprint with its courtyard box in board coordinates, side, pose, pads and nets, the outline, per-net footprint clusters and an MST ratsnest estimate), `kicad_move_footprint` (one footprint or a batch through `update_items`, ONE native undo step named "Adom: place U1", after the revision check, a same-side courtyard collision check that names the offending refs, and the outline check; `dryRun` reports without writing; a side change flips through the layer field, which is how KiCad's `FOOTPRINT::Deserialize` flips) and `kicad_placement_validate` (all-pairs courtyard overlaps, outside and parked footprints, the ratsnest total, KiCad DRC on the live snapshot filtered to the placement violation types with the routing verbs' truncation flags). Live when the PCB editor has the board on the IPC API; the reads and a dryRun answer from the file otherwise. Core geometry in `kicad-core/src/placement.rs` (courtyard extraction added to the PCB parser), IPC glue beside the routing operations in `ipc.rs`, the verb group in `verbs_placement.rs`. The demo fixture is the public Adom ESC G431 board: `tools/make_placement_fixture.py` (standard-library Python, a byte-preserving s-expression edit) writes an unplaced copy (149 footprints parked in a grid to the right of the outline, 434 segments, 384 vias, 57 zone fills and 66 teardrop zones removed, the 49 user zones kept) and an unrouted copy, under `demo/placement/`; both pass service-kicad DRC (KiCad 10.0.2: zero courtyard overlaps in the parked copy, 271 unconnected items as expected, the source board's own six malformed courtyards and seven intra-footprint pad clearances inherited) and the bridge's parser (integration test). 241 tests. Runner: placement_state and placement_validate in phase 2, move_footprint dryRun in phase 2.6. Live verification on a desktop is pending (John runs it). |+| 2026-09-12 | Live test of `kicad_move_footprint` on KiCad 10.0.3 found the anchor moving without the body: pads and courtyard stayed in the parking lot while `verified` said true. Root cause in KiCad's `api_handler_pcb.cpp` and `footprint.cpp` (10.0 branch): `UpdateItems` does not edit a footprint in place, it builds a new one from our FootprintInstance proto, removes the old one and adds the new one, and `FOOTPRINT::Deserialize` takes every child (pads, fields, shapes, texts, zones, dimensions) at the absolute board coordinates and angles the proto carries. Fix in 1.0.2's tree: `build_footprint_updates` now applies the move's rigid-body transform to every child client-side (p' = N + R(delta)(p - O), angles + delta, KiCad's y-down RotatePoint), the four mandatory fields through the typed proto and every `definition.items` payload on the protobuf wire format by field number (`placement::transform_child`; the crate keeps the proto types private), covering Pad, BoardText, Field, BoardTextBox, BoardGraphicShape (segment, rectangle, arc, circle, polygon with arc nodes, bezier; an off-cardinal rectangle becomes a polygon as KiCad's own rotate does), Zone (outline and fills), Dimension (all five styles, with KiCad's orthogonal axis rule), ReferenceImage and Barcode; Group and Footprint3DModel pass through; anything else refuses the move with `unsupported_child_item`. Side changes are refused with `side_change_not_supported` until a mirror lands. The post-commit check now compares the read-back courtyard box centre (or the first pad) with the expected transformed value within 0.01 mm and reports `children did not move` with the numbers instead of trusting the anchor. 210 core tests. Live re-test on a desktop pending (John runs it). | ## The goal in one sentence @@ -292,3 +294,7 @@ Total: roughly 18 to 21 weeks of one AI thread's time with human test time on th - 2026-09-12 (later): comparison.md reworked after John's review. The three "where others lead" items are now "three claims we chose not to match" (KiCad reads its own files, permissions live in the AI harness and in ab, discovery is skills plus describe plus wiki auto-discovery). SPICE simulation added as the one open gap with an ecosystem-wide plan (ngspice runner in the container, kicad_export_spice, kicad_simulate); verified kicad-cli spice export and bundled ngspice on ConfRoomROG. PCM proof in progress (sample repository on the page, install, bump, update). - 2026-09-12 (later): PCM proof passed end to end on ConfRoomROG (KiCad 10.0.5) and is written up in [pcm.md](pcm.md). The sample repository on this page (three anonymous files, `no-cache` JSON) was registered with `kicad_pcm_add_repository`, installed with `kicad_pcm_install` (1.0.0, `PCM_Adom_Sample` registered by KiCad itself on launch), bumped to 1.1.0 with the packer and pushed, seen by `kicad_pcm_list` (`updatesAvailable: 1`) and by KiCad's own PCM dialog after a restart (Update button on the card), updated the user's way through UI Automation (Update All, Apply Pending Changes, Close) with the Symbol Editor showing `R v1.1.0`, then bumped to 1.2.0 and updated headlessly with `kicad_pcm_install {update:true}` (`previousVersion 1.1.0, version 1.2.0, updated true`), which KiCad's dialog showed without a restart. Two fixes came out of it, both in the build that ships as 1.0.2: the automatic dialog sweep no longer closes tool windows opened on purpose (it had pressed Close on "Applying Package Changes" mid-apply and aborted the first update), and `kicad_send_key` gained `menu` (a label substring fired on the window's menu bar as a background menu command; how the PCM dialog is opened). New verbs: `kicad_pcm_add_repository`, `kicad_pcm_remove_repository`, `kicad_pcm_list` with the update check, `kicad_pcm_install {update:true}`; documented in SKILL.md under "PCM repositories on the wiki" and covered by the verb runner (real add in phase 4, conditional remove in phase 6). Three KiCad facts recorded for the port: `installed_packages.json` is written when the PCM dialog closes, Refresh inside an open dialog does not recompute the installed-package update flag (the startup check does), and the per-card Update button is not exposed to UIA while Update All, Apply Pending Changes, Refresh and Close are.++- 2026-09-12 (evening): etiquette decision order fixed after the 1.0.3 gate on arav-rog flagged one steal: with the Windows foreground lock off, a bridge-spawned KiCad window comes forward twice while it loads, and the once-ledger left the second on top of the user's Hydrogen. The "already bounced" row now sits below the rows that mean the bridge is the cause (guard active, verb in flight, fresh spawn), so those always bounce; user-initiated foregrounds stay excluded above. ESC placement round: fixture, planner and Claude Fable 5.1 take recorded (25 commits, 36.5 s, zero overlaps), evidence on the page under demo/placement/fable.++- 2026-09-12 (evening, continued): the gate on ConfRoomROG kept flagging kicad_open_board as a foreground steal after the decision-order fix. Measured cause: a Z-order push (HWND_BOTTOM) does not hold while the editor still owns the activation, and the bridge process's own SetForegroundWindow on the user's window reports success without taking effect (no foreground rights), whereas ab's desktop_bring_to_front sticks. Fix: the bridge now has a Z-order top query (what actually covers the user), spawn verbs wait for the editor's load dialog to go away and then run the restore twice, and the restore hands the activation back to the user's window through ab with its window state preserved; the etiquette loop remembers the user's last non-KiCad foreground and restores it on every bounce. Gate on ConfRoomROG: 88 pass, 0 fail, 16 declared skips, zero steals. 1.0.3 published to insiders with the placement verbs.
skills/kicad-bridge-test/run_verb_tests.py+26@@ -84,6 +84,13 @@ CHECKS = { # pcm_list from 1.0.2 carries the update-check fields; a bridge without them fails this check on purpose "pcm_listed": lambda r: ok(r) and isinstance(r.get("packages"), list) and "updatesAvailable" in r, "repo_added": lambda r: ok(r) and (r.get("added") is True or r.get("alreadyRegistered") is True),+ # placement (1.0.3): live when the PCB editor has the board on the IPC API, the file otherwise+ "placement_state": lambda r: ok(r) and "revision" in r and isinstance(r.get("footprints"), list)+ and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),+ "move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False+ and "checks" in r and r.get("undoSteps") == 0,+ "placement_validated": lambda r: ok(r) and isinstance(r.get("courtyardOverlaps"), list)+ and "placed" in r and "drc" in r, } def matrix(w: dict) -> dict:@@ -140,6 +147,15 @@ def matrix(w: dict) -> dict: note="file diagnostics only; renderVerified is always false here"), "autoroute": dict(phase=2, args={"filePath": B, "engine": "ai", "dryRun": True}, timeout=120, note="ai engine returns a plan request and mutates nothing"),+ # placement (1.0.3): a real read on the scratch board (file fallback here, since no PCB editor+ # has it on the IPC API in phase 2), a dryRun move checked against that read, and the validate.+ "placement_state": dict(phase=2, args={"filePath": B}, check="placement_state", timeout=120,+ note="footprints with courtyards, nets and the MST ratsnest; source file or live-editor"),+ "move_footprint": dict(phase=2.6, args={"__special": "move_footprint_dry"}, check="move_dry", timeout=120,+ note="SAFETY: dryRun only. Moves the first footprint placement_state reported to its own "+ "position, expectedRevision from that read; the checks run, nothing is written"),+ "placement_validate": dict(phase=2, args={"filePath": B}, check="placement_validated", timeout=200,+ note="courtyard overlaps, outline, parked, ratsnest, KiCad DRC filtered to placement types"), # schematic edit in place, on the fixture copy: each write keeps a .bak beside the file "sch_place_symbol": dict(phase=2, args={"filePath": S, "libId": "Device:R", "reference": "R_VERBTEST", "at": [200, 150], "value": "1k"}, timeout=150,@@ -251,6 +267,7 @@ SPECIALS_DOC = """Specials are composed calls the runner fills at runtime: install_footprint -> installs a generated 2-pad test footprint ADOM_VERBTEST into Adom.pretty place_footprint -> places Adom:ADOM_VERBTEST on a scratch copy of the template board pcm_remove_repository -> removes the sample PCM repository row only if pcm_add_repository added it this run+ move_footprint_dry -> dryRun move of the first footprint placement_state listed, to its own position, with that read's revision """ TEST_SYMBOL = '''(symbol "ADOM_VERBTEST" (pin_numbers hide) (in_bom yes) (on_board yes)@@ -503,6 +520,15 @@ def main(): results[verb] = {"result": "SKIP", "reason": "sch_place_symbol returned no uuid to delete"} return args = {"filePath": w["sch"], "uuid": uid}+ elif special == "move_footprint_dry":+ st = (results.get("placement_state") or {}).get("response") or {}+ fps = st.get("footprints") or []+ if not st.get("revision") or not fps:+ results[verb] = {"result": "SKIP", "reason": "placement_state returned no revision or no footprints to move"}+ return+ first = sorted(fps, key=lambda f: f.get("ref") or "")[0]+ args = {"filePath": w["board"], "expectedRevision": st["revision"], "ref": first["ref"],+ "x": first["x"], "y": first["y"], "dryRun": True} elif special == "pcm_remove_repository": added = (results.get("pcm_add_repository") or {}).get("response") or {} if added.get("added") is not True: