app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Revert PR #392: linux: KiCad on Linux (Wayland and X11): windows, menus, dialogs, screenshots, background launch, simulate; Windows unchanged
20 files changed
+137−17149
diff truncated
README.md+2−658RELEASE_NOTES.md+1−1100SKILL.md+3−522bridge.json+4−470docs/comparison.md+4−222rust/Cargo.lock+80−1865rust/README.md+1−57rust/crates/kicad-bridge/src/main.rs+1−288rust/crates/kicad-bridge/src/verbs_maint.rs+4−364rust/crates/kicad-bridge/src/verbs_sim.rs+5−317rust/crates/kicad-bridge/src/verbs_std_more.rs+1−625rust/crates/kicad-bridge/src/verbs_windows.rs+10−3835rust/crates/kicad-core/src/install.rs+1−2883rust/crates/kicad-core/src/pcb.rs+2−1774rust/crates/kicad-core/src/sch_render.rs+1−578rust/crates/kicad-core/src/spice.rs+2−651rust/crates/kicad-platform/Cargo.toml+2−47rust/crates/kicad-platform/src/lib.rs−407rust/crates/kicad-platform/src/linux.rs+13−447rust/crates/kicad-platform/src/macos.rs−39README.md+2−658@@ -1,669 +1,13 @@-# Adom Bridge: KiCad Bridge--A **reverse bridge** that lets Adom Bridge (ab), and the AI driving it, control the user's own-KiCad on Windows: open and drive every editor, install symbol/footprint libraries, place parts, run-DRC/ERC, export manufacturing files, and screenshot any window back to the AI.--> ### Two artifacts on this page, which one do you want?->-> | | **Release ZIP** (`adom-bridge-kicad-v*.zip`) | **Package** (`adom-wiki pkg install adom/kicad-bridge`) |-> |---|---|---|-> | What it is | The **bridge runtime**: the native Rust `kicad-bridge.exe` that drives KiCad (the Python server it replaced is kept in the repo for reference) | **Skills + docs only**, for a container/agent |-> | Who loads it | **Adom Bridge** downloads it automatically via `adom-bridge-kicad-manifest.json` | You, into a container, to teach an agent the `kicad_*` verbs |-> | Contains | code, certs, UIA scripts, board template | `SKILL.md` files (no binaries, no images) |-> | You install it manually? | **No**: Bridge handles it | Yes |->-> **The package is not the bridge.** Installing the package does not give you a working-> bridge; Adom Bridge loading the release zip does. Both are kept deliberately small, the zip is-> auto-bundled into Adom Bridge, so it ships only what the runtime needs.--KiCad is the user's **host app**. The bridge *detects* an existing KiCad and, if none is present,-*installs* one for them, it never asks the user to download or click through anything by hand.--- **Page / docs:** https://wiki.adom.inc/adom/kicad-bridge-- **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk-- **Verb namespace:** `kicad_*` · **Status verb:** `kicad_bridge_status` · **Health:** `GET /status`--> Every screenshot in this README was captured on **ADOMBASELINE**, a stock Hyper-V VM with **no-> GPU**, driven end-to-end through the bridge, install → libraries → editors → 2D → 3D. If it-> renders there, it renders on a real laptop.---## The demo, recorded (2:47, narrated)--<video width="100%" controls poster="/blob/app/kicad-bridge/docs/videos/kicad-demo-tour-poster.jpg">- <source src="/blob/app/kicad-bridge/docs/videos/kicad-demo-tour.mp4" type="video/mp4"></video>--**[Watch the recording](https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/docs/videos/kicad-demo-tour.mp4)**: the guided-tour exactly as a user gets it from the dashboard's Play the demo button, recorded on a real Windows desktop.-Six beats on the Adom ESC G431 motor controller pulled from the wiki: the STM32 symbol in the Symbol Editor,-its LQFP-48 footprint, the chip in 3D, the full schematic, the 2D board and the 3D board with every MOSFET-in place. Each window is brought forward once, captioned, and moved (zoom, pan, orbit) while the narration-explains it. The same recording plays inline in the dashboard.--How the dock card, the dashboard and this demo fit together: [docs/dashboard.md](docs/dashboard.md).--## Feature guides | guide | what it covers | |---|---|-| [KiCad on Linux](docs/linux.md) | The same bridge on Ubuntu, Debian, Fedora and other distributions, Wayland or X11: what you need, how windows, screenshots, menus and dialogs work there, what differs from Windows, and which verbs are Linux-ready. | | [Dashboard](docs/dashboard.md) | The dock card, the dashboard and how the demo fits in. | | [How the bridge compares](docs/comparison.md) | Feature matrix against every notable KiCad MCP server and AI tool, with the gaps on both sides. | | [The Rust port plan](docs/rust-port-plan.md) | One dependency-free binary: what moves to KiCad's API, what ab already does, what PowerShell goes away, phases and risks. |-| [The ESC G431 demo: placement and routing by three engines](docs/esc-demo.md) | A real public Adom board placed and routed from scratch by Claude Fable 5.1, GPT-6 Astra and Freerouting through the bridge, with real times, result quality, tokens, dollars and agent counts per engine. |-| [Native zone plans](docs/native-zone-plan.md) | `kicad_apply_zone_plan`: one Undo step, full-candidate DRC, the no-op and refill contracts. |-| [Native silkscreen text](docs/native-silk-text.md), [graphics](docs/native-silk-graphics.md) and [fields](docs/native-silk-fields.md) | The stable-ID silk batch verbs, what each refuses, and what KiCad 10.0.5 will not do over IPC. |-| [Linked view refresh](docs/linked-view-refresh.md) | `kicad_refresh_board_views`: refreshing one PCB editor and its bound 3D viewer in the background. |-| [Astra's working notes](docs/CODEX-ASTRA.md) | How the AI Flow thread contributes to this bridge: preflight, branch PRs, acceptance. The three `*-astra-prompt.md` files beside it are the prompts used for the ESC demo runs. |-| [KiCad wishlist: automation without the foreground](docs/kicad-wishlist.md) | Every place the bridge needs UI automation, real input or the foreground because KiCad has no API for it, ranked worst first, with the feature request that would fix each. |-| [Your wiki page as a KiCad PCM repository](docs/pcm.md) | The page serves `repository.json`, `packages.json` and the zips anonymously, so it is a Plugin and Content Manager repository as-is: the sample library package, the packer, adding the repository in KiCad or with `kicad_pcm_add_repository`, and updates through PCM's Update button or `kicad_pcm_install {update:true}`. |--## Live routing (from 0.9.340)--Four verbs edit copper in the open PCB editor through KiCad's official IPC API (KiCad 10.0.1+-with the IPC API server enabled): `kicad_routing_state`, `kicad_route_net`, `kicad_remove_route`-and `kicad_routing_validate`. The AI chooses the waypoints; each call lands as one native Undo step,-carries a revision guard, and is DRC-checked against a snapshot before it commits. This is not an-autorouter and not an electrical sign-off. Details and the regression demo: [SKILL.md](SKILL.md#live-routing-from-09340)-and [demo/routing/README.md](demo/routing/README.md). The guide to both engines, with a live run and screenshots: [docs/autorouting.md](docs/autorouting.md).--## The board model: the same board data as Altium and Fusion (from 1.0.59)--`kicad_routing_state {"model":"standard"}` reads the whole board in the-[EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard)'s shape, the one `altium_routing_state` and-`fusion_routing_state` answer: tracks, arcs, vias, pads with their sizes (a custom pad as its bounding rectangle),-parts, copper pours, the board outline with its arcs, cutouts and keepouts, the unconnected count worked out from the-copper itself (with the pads of each piece to join) and a revision that only moves when copper, parts or the outline-move. Millimetres, y up, KiCad layer names. It reads the live PCB editor (unsaved edits included) or the-`.kicad_pcb` file with KiCad closed. The edit verbs take that revision as `expectedRevision`, and with-`frame:"standard"` they take its coordinates as they are.--Its companions, by the standard's names:--| Verb | What it tells you |-|---|---|-| `kicad_layer_stack` | the stack-up: copper thickness per layer, the dielectric under each (thickness, Dk, material), planes and their nets, masks, finish |-| `kicad_pour_state` | the copper actually poured: filled area per zone and per layer, islands, zones that poured nothing |-| `kicad_bodies` | each part's 3D body as a box and its height, read from the 3D model files: the tallest parts, what a height check cannot see |-| `kicad_netlist` | the netlist as `{net: ["U1.8", ...]}`, compared pin by pin with the nets you expect, with every pin's electrical type |--And the standard's quick reads, each a view of the same model: `kicad_board_summary` (parts by side, nets, layers in-use, board size and area), `kicad_list_nets` (with pad counts), `kicad_list_designators`, `kicad_copper_layers`,-`kicad_layers_in_use`, `kicad_component_data` (a part's placement, size and nets) and `kicad_component_pins` (its pads-with net, position, size and hole).--Checked against KiCad's own DRC on six of KiCad 10's demo boards: the same unconnected count on every one. The-details, the rules and the pitfalls: the [kicad-board-model](skills/kicad-board-model/SKILL.md) skill.--## The demo verb: `kicad_demo`--One verb that shows the whole bridge off, built for the AI that demos Adom Bridge-during Hydrogen's install. Six beats, in the order a hardware person thinks:--| Beat | Shows |-|---|---|-| 1 | the **schematic symbol** in the user's own Symbol Editor |-| 2 | the **footprint** for that same part |-| 3 | that part in **3D** |-| 4 | a **schematic** using it (U1 + two 10k resistors) |-| 5 | the **2D board layout** |-| 6 | the finished **board in 3D** |--```bash-adom-bridge kicad_demo '{}' # start: prepares, warms KiCad, opens beat 1-adom-bridge kicad_demo '{"step":"footprint"}' # ...then follow data.nextCall each time-adom-bridge kicad_demo '{"all":true}' # bulk (non-interactive; slower than one request budget)-adom-bridge kicad_close '{"force":true}' # tear down-```--**It narrates itself.** Every beat returns `say` (a line to speak), `pointOut`-(what's actually on screen), `window` (match it in `kicad_screenshot_all`) and-`nextCall`. One beat per call is the default precisely so the AI can talk, screenshot,-then move on.--**It ships nothing.** The two demo parts, the schematic and the board are all-generated in code, and the 3D bodies come from KiCad's *own* bundled-`3dmodels/*.3dshapes`: so the runtime zip stays small and the 3D views are real.-Parts install into the user's real `Adom` library; the project lands in-`Documents/adom-kicad-demo`.--**No KiCad? It offers to install it.** `kicad_demo` returns an offer instead of an-error; `kicad_demo '{"installKiCad": true}'` silently installs the official build-(per-user, no UAC) and then runs the tour. Watching the AI install your EDA tool is-itself a good demo beat.--**It never takes your screen.** Every window opens in the background and all input is-window-targeted (UIA / `PostMessage`), so you can keep working while it runs.---## How it fits together--The bridge is one native Rust executable, `kicad-bridge.exe`, that Adom Bridge launches on the user's-machine (`port: 0`: Adom Bridge picks the port and passes `ADOM_BIND_HOST`). There is no Python or other-runtime to provision. It speaks HTTP-(`POST /command`, `GET /status`) and reaches KiCad through **four control surfaces**, picking the-lightest one that can do the job.----| # | Surface | Used for | Where |-|---|---------|----------|-------|-| 1 | **kicad-cli** (subprocess) | headless DRC/ERC, gerber/pdf/svg/step/bom export, format upgrade | `handlers/export.py`, `run_drc`, `run_erc`, `lint_*` |-| 2 | **KiCad IPC (kipy)** | board/schematic introspection, confirmed footprint placement (KiCad 9+) | `handlers/place_footprint.py` |-| 3 | **Embedded Python** | JSON-RPC *inside* each KiCad process, dispatched on its wx UI thread | `plugin_payload/adom_bridge.py`, `handlers/bridge_client.py` |-| 4 | **Win32 / UIA / SendKeys** | open editors, screenshots, clicks/keys, dialog handling, window management | `handlers/kicad_ui.py`, `handlers/close_windows.py` |---## The window tour--Everything below was opened and captured on the GPU-less VM. Sample data is the **RP2040 breakout**-generated by `tour-pack-rp2040/` (an `AdomRP2040` symbol + `QFN-56_AdomRP2040` footprint + a board).--### Schematic editor: `kicad_open_schematic`--The RP2040 breakout: the MCU (U1), USB-C, a 12 MHz crystal, decoupling, and mounting pins.----### Symbol editor: `kicad_open_symbol_editor`--Opens straight to a symbol (`kicad_install_symbol` puts it in a user library first).----### Footprint editor: `kicad_open_footprint_editor`--`{"footprintName":"QFN-56_AdomRP2040","library":"AdomRP2040"}` loads the part directly, 56 pins +-thermal pad, 7×7 mm, 0.4 mm pitch, courtyard and silkscreen.----### PCB editor (2D): `kicad_open_board`--The routed breakout, copper, silkscreen, the QFN-56 land pattern, mounting holes.----### 3D viewer: `kicad_open_3d_viewer {"editor":"pcb"}`--The full board in 3D, board body, the USB-C connector's 3D model, the QFN chip body, SMD parts,-plated through-holes. **Rendered on the CPU via the software-OpenGL fallback (below); reload 1.7 s.**-----## Install & upgrade: zero manual steps--The bridge never tells the user to go download KiCad. `kicad_upgrade` fetches the official installer-and runs it silently, picking the scope automatically:--- **Elevated Bridge** → `/allusers /S` (system-wide, `%ProgramFiles%\KiCad`).-- **Non-elevated Bridge** → `/currentuser /S` (`%LocalAppData%\Programs\KiCad`, **no UAC prompt**).--`kicad_readiness` reports whether KiCad is installed and ready without side effects; the AI routes on-it before offering to install. Hard-won install details (all handled for you):--- KiCad 10's NsisMultiUser installer **requires** a scope flag, bare `/S` errors `rc=666660`.-- Bridge's portable Python has **no CA bundle**; the bridge ships `certs/cacert.pem` and uses it for TLS.-- Downloads are size-checked against `Content-Length` (a truncated installer otherwise fails at NSIS).-- `%APPDATA%/kicad/<ver>/` lib tables don't exist until first launch, the bridge **bootstraps** them- so `install_library`/`install_footprint` work on a never-opened KiCad.-- `kicad_upgrade {"diagnoseOnly":true}` reports token-elevation type + `EnableLUA` without installing.---## Dialogs & error handling: the bridge clears the pointless ones--KiCad throws modal dialogs that stall automation. The bridge **scans every window owned by a running-KiCad process** (by PID, reliable, unlike title matching), auto-expires the benign ones, screenshots-what it dismissed, and returns a hint so the AI can decide what (if anything) to tell the user.--Auto-expired benign dialogs include:--- **"Could not use OpenGL / falling back to software rendering"** (GPU-less hosts).-- **"Welcome to KiCad, starting for the first time"** first-run wizard (dismissing accepts defaults).-- **"This file was created by an older version of KiCad"** conversion notice.----Verbs:--- `kicad_window_info`: lists windows and **self-heals** (auto-expires benign dialogs) by default.-- `kicad_dismiss_dialogs {"all":true}`: clear everything blocking; `{"screenshot":true}` returns- images of each; `{"forceSoftwareCanvas":true}` persists Cairo canvas; `{"debug":true}` dumps the- raw PID-based scan.-- `kicad_screenshot_all`: one call returns **every** open KiCad window, so the AI can spot an error- dialog it didn't expect and read the message.---## Software-OpenGL fallback (last resort)--`kicad_enable_software_opengl` deploys Mesa's `llvmpipe` (a CPU OpenGL rasterizer) into KiCad's `bin`-so a box with **no usable GPU**: Hyper-V, RDP, headless CI, can still render the editors and the 3D-viewer. This is how every 3D shot above exists.--> ⚠️ It renders on the CPU and is **slow**. It is a worst-case fallback only, a real GPU (or GPU-P /-> DDA passthrough) is vastly better. The bridge keeps it so a GPU-less box isn't a dead end; it is-> never suggested proactively.---## Verb reference--<!-- verbs:start (tools/readme_verbs.py writes this) -->--Every verb of this build (342, `kicad_describe` is the live catalog with inputs, hints and pitfalls). A name marked *standard* is the EDA Bridge Standard's name for the verb after the arrow.--**Board model and reads (EDA Bridge Standard)** (12)--| Verb | What it does |-|---|---|-| `kicad_board_summary` | One-screen summary of the board: parts by side, nets, pads, copper layers and those actually in use, the outline's size and area, unconnected and the revision. |-| `kicad_bodies` | Every part's 3D body as a box: its footprint rectangle on the board and its height range above the surface it sits on, read from the 3D model files the footprints name. For height checks, enclosure fit and placement review. |-| `kicad_component_data` | Placement data for one part or all of them: footprint, value, side, position, rotation, the size of its pads' extent, pad count and the nets on its pads. |-| `kicad_component_pins` | Every pad of one part with its net, position, layer, size, rotation and hole. |-| `kicad_copper_layers` | The board's copper layers top to bottom by KiCad's names (F.Cu, In1.Cu.., B.Cu). |-| `kicad_layer_stack` | The board's stack-up: copper layers top to bottom with copper thickness and the dielectric under each (thickness, Dk, material), the masks and the board thickness. For impedance, current capacity and fab quotes. |-| `kicad_layers_in_use` | Which copper layers actually carry copper (tracks, arcs, vias, pads, pours), densest first, and which are empty. |-| `kicad_list_designators` | Every part designator on the board, in natural order (R2 before R10). |-| `kicad_list_nets` | Every net on the board with its pad count. |-| `kicad_netlist` | The compiled netlist in the EDA Bridge Standard's shape: every net with its pins as Ref.Pin across every sheet (hierarchy resolved); or one net; or compare with the nets you expect. parts:true adds each part's pins with their electrical types. |-| `kicad_pour_state` | Every copper pour (zone) on every layer with the copper KiCad actually poured: filled area per zone and per layer, islands, and the zones that poured nothing. |-| `kicad_routing_state` | Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity. |--**Schematic** (45)--| Verb | What it does |-|---|---|-| `kicad_annotate` | *standard* → `kicad_sch_annotate` |-| `kicad_design_review` | Schematic design review from the netlist: ICs without a decoupling capacitor on each power-pin net, I2C lines without pull-ups, floating or undriven reset and enable pins, LEDs without a series resistor, regulators without input or output capacitors, rails without bulk capacitance, unannotated or duplicate references. Each finding has severity, refs, nets, why and how to fix. |-| `kicad_render_schematic` | Render a schematic sheet to PNG (kicad-cli sch export svg, rasterised in the bridge), and keep visual baselines: baseline:"set" stores the image under <project>/.adom/baselines/, baseline:"compare" reports the pixel drift percentage, the changed region and a diff image. |-| `kicad_sch_annotate` | Number every X? reference on a .kicad_sch the way eeschema's Annotate does: the first free number per prefix across the project's sheets, in position order, units of one multi-unit part sharing a designator (edits the file on disk). |-| `kicad_sch_batch` | Apply a list of schematic edit operations to one .kicad_sch in ONE read and ONE atomic write: all or nothing, one ERC before and after, dryRun reports the plan. |-| `kicad_sch_bus` | Draw a bus polyline on a .kicad_sch (one bus segment per pair of points), optionally with a bus label such as D[0..7] at its first point (edits the file on disk). |-| `kicad_sch_bus_entry` | Add a 45-degree bus entry (the short diagonal between a member wire and a bus) at a point (edits the file on disk). |-| `kicad_sch_check` | Connectivity and layout check of a sheet from the file: dangling wire ends, floating labels, unconnected non-passive pins, nets that reach only one pin, distinct net names shorted together, no-connect flags on wired pins, and with overlaps:true symbol, label and text collisions (symbol body and pin extents). Each finding has a location and a fix. |-| `kicad_sch_components` | List the placed symbols of a .kicad_sch: reference, value, footprint, lib id, every unit with position, rotation and mirror, and every field. |-| `kicad_sch_connect` | *standard* → `kicad_sch_connect_to_net` |-| `kicad_sch_connect_pins` | Wire two pins by Ref.Pin with a horizontal and vertical (L-shaped) route, adding junction dots where the route makes a T with existing wires (edits the file on disk). |-| `kicad_sch_connect_to_bus` | Fan a set of pins onto a bus: per pin a wire to just short of the bus, a 45-degree bus entry onto it, and a member label (D0, D1 ...) on the wire (edits the file on disk). |-| `kicad_sch_connect_to_net` | Connect one or many pins to a named net: a short stub wire away from the body plus a net label (local, global, hierarchical) or a power symbol at its end (edits the file on disk). |-| `kicad_sch_create` | Create a blank KiCad 10 schematic (.kicad_sch) with a paper size and title block; optionally add it to an existing project as a hierarchical sheet of the root, or make it the root of a new project. |-| `kicad_sch_delete` | Delete any top-level item (symbol, wire, label, junction, sheet ...) from a .kicad_sch by uuid, every unit of a symbol by reference, or labels by name (edits the file on disk). |-| `kicad_sch_delete_wire` | Delete wires (or bus segments) by uuid, by their two endpoints, or every wire through a point, and prune junction dots that no longer join three or more items (edits the file on disk). |-| `kicad_sch_edit` | *standard* → `kicad_sch_set_property` |-| `kicad_sch_edit_label` | Edit net labels on a .kicad_sch: rotate (with the justify KiCad pairs with the angle), move to a point or by a delta, rename, or move every label of a net by (dx, dy) (edits the file on disk). |-| `kicad_sch_fix_connectivity` | Snap near-miss wire ends onto the pin, label, sheet pin or wire end they almost touch (within a tolerance). dryRun (the default) lists the moves; dryRun:false writes them. |-| `kicad_sch_hierarchy` | The sheet tree of a design, recursively: each sheet's name, file, uuid, instance path, page, position, size and pins; renumber:true reassigns page numbers depth-first (root 1). |-| `kicad_sch_items` | List the wiring items of a .kicad_sch with uuids and coordinates: wires, buses, bus entries, labels (local, global, hierarchical), power symbols, junctions, no-connects, texts, sheets. |-| `kicad_sch_junction` | Add junction dots at one or many points, or remove them by uuid or point (edits the file on disk). |-| `kicad_sch_label` | Add a net label (local, global, hierarchical) or a power symbol on a .kicad_sch (edits the file on disk). |-| `kicad_sch_move` | Move a placed symbol (by reference) so its anchor lands on a new point or by a delta; every unit of a multi-unit part and every field move with it, and stretchWires keeps the attached wires on the pins (edits the file on disk). |-| `kicad_sch_move_region` | Move everything inside a box by (dx, dy): symbols by their anchor, wire ends inside the box (wires crossing the edge stretch), labels, junctions, no-connects, texts, bus entries, sheets (edits the file on disk). |-| `kicad_sch_no_connect` | Put no-connect flags on pins of a .kicad_sch (an intentionally open pin: a spare GPIO, an unused connector pin) so ERC stops reporting them, or remove flags (edits the file on disk). |-| `kicad_sch_page` | Read or set a schematic's page: paper size (A5 to A0, A to E, USLetter, USLegal, USLedger, custom mm), orientation and title block (title, date, rev, company, comments 1 to 9). |-| `kicad_sch_pins` | Every pin's absolute (x, y) on a .kicad_sch for the given references (rotation and mirror applied), with number, name, electrical type, outward direction, and the wires, labels and net touching it. |-| `kicad_sch_place_part` | *standard* → `kicad_sch_place_symbol` |-| `kicad_sch_place_symbol` | Place a library symbol on a .kicad_sch (edits the file on disk; the lib_symbols entry is copied from the library when the sheet lacks it). |-| `kicad_sch_replace_symbol` | Change the library symbol of a placed part (lib id), keeping its units, fields and position; the new lib_symbols entry is copied from the library (edits the file on disk). |-| `kicad_sch_reset_fields` | Put a symbol's Reference and Value (or the named fields) back at the library symbol's offsets, angle and justify, through the unit's rotation and mirror (edits the file on disk). |-| `kicad_sch_rotate` | Rotate a placed symbol to an absolute angle (0, 90, 180, 270); every unit turns by the same delta about its own anchor and the fields turn with it (edits the file on disk). |-| `kicad_sch_set_property` | Set or add a field on a placed symbol by reference: Value, Footprint, Datasheet, a custom field, or Reference itself (edits the file on disk). |-| `kicad_sch_sheet` | Hierarchical sheets on a page: add (creates the child file when missing and links it), edit (name, file, size, position), move, delete (the child file is kept), duplicate (a new child file with fresh uuids and free references). |-| `kicad_sch_sheet_pins` | A sheet's pins: list, add, edit (rename, shape, move along an edge), delete; import:true adds a pin for every hierarchical label in the child not yet on the sheet; validate:true reports labels without pins, pins without labels and shape mismatches. |-| `kicad_sch_sheet_symbol` | *standard* → `kicad_sch_sheet` |-| `kicad_sch_split_wire` | Split the wire passing through a point into two wires there, and put a junction dot at the cut (edits the file on disk). |-| `kicad_sch_swap_part` | *standard* → `kicad_sch_replace_symbol` |-| `kicad_sch_text` | Add free text (a note, a title, a design comment) to a .kicad_sch with size, angle, justify, bold and italic (edits the file on disk). |-| `kicad_sch_trace_point` | Everything electrically connected at a point on a sheet: the wires, labels, pins, junctions, sheet pins and no-connect flags there, and for each net they belong to the whole net (pins, labels, power symbols, wires) with its name. |-| `kicad_sch_update_from_library` | Re-embed the lib_symbols definitions of a .kicad_sch from their libraries (all, or the listed lib ids) and report which changed (edits the file on disk). |-| `kicad_sch_wire` | Draw a wire polyline on a .kicad_sch (one wire segment per pair of points; edits the file on disk). |-| `kicad_sch_zoom` | Find parts on a schematic sheet and frame them: where each sits (mm), which are not on the sheet, and a proof frame: KiCad's own plot of the sheet cropped to the parts, each outlined. The schematic half of cross-probing (kicad_zoom_to is the board half). |-| `kicad_update_pcb` | *standard* → `kicad_update_pcb_from_schematic` |--**Symbols, footprints and libraries** (34)--| Verb | What it does |-|---|---|-| `kicad_adom_library_heal` | Reconcile a SPLIT Adom library: merge the orphaned file's symbols into the registered one (default), repoint the row, or prune the symbols KiCad refuses. Human-chosen; dryRun previews. |-| `kicad_adom_library_status` | Report Adom library install state across KiCad versions. |-| `kicad_create_footprint` | *standard* → `kicad_fp_create` |-| `kicad_create_symbol` | *standard* → `kicad_sym_create` |-| `kicad_export_part` | One-off single-part library exports: a .kicad_sym with ONLY that symbol, a .pretty with ONLY that footprint, the bare 3D model file. The reverse of install. |-| `kicad_find_symbol` | *standard* → `kicad_search_library` |-| `kicad_fp_create` | Write a new footprint (.kicad_mod, KiCad 10) into a .pretty folder from a pad list: courtyard generated with a margin, fab outline, silkscreen box clear of the pads with a pin-1 dot, reference and value text, optional 3D model. |-| `kicad_fp_edit` | Edit a .kicad_mod in place: edit, delete, add or renumber pads; append, replace or delete graphics on a layer (line, arc, rect, circle, poly); set description, tags and attributes; replace, append or delete 3D models (path, offset, scale, rotate). Everything not edited stays byte-identical. |-| `kicad_fp_info` | Full details of one footprint: pads (number, type, shape, position, size, drill, layers), layers used, courtyard box, graphics per layer, attributes, properties and 3D models. |-| `kicad_install_extension` | Install the bridge's extension into the tool (the standard's install_extension). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for. |-| `kicad_install_footprint` | Write a .kicad_mod into the global Adom.pretty footprint library, register it in fp-lib-table, optionally install and wire a 3D model. |-| `kicad_install_library` | Register a symbol/footprint library in the user's sym-/fp-lib-table (footprint installs also carry a bundle's 3D models). |-| `kicad_install_library_bundle` | Install an Adom wiki Cloud Library bundle zip (symbols + footprints + 3D models) in one call. |-| `kicad_install_plugin` | Retired: the native bridge installs no Python plugin into KiCad. Returns success with nothing to do so old scripts keep working. |-| `kicad_install_receipt` | The last extension install's receipt (the standard's install_receipt). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for. |-| `kicad_install_symbol` | Merge the symbol(s) of a .kicad_sym into the single global Adom library (Adom.kicad_sym), creating and registering it on first use. |-| `kicad_lint_library` | Lint a symbol/footprint library: the bridge's structural check, KiCad's own verdict (kicad-cli), and the standard's content rules (pads, courtyard, origin, silkscreen, 3D body, description; pins, footprint link, pins against pads, part number), each finding with its rule and severity. |-| `kicad_list_footprints` | What footprints actually exist, per library: the fp-lib-table (URIs expanded, stock libraries included) plus unregistered .pretty folders in the user dir. |-| `kicad_list_symbols` | Names in a registered symbol library, with KiCad's own inventory. |-| `kicad_make_part_project` | Generate a small real project (pro/sch/pcb) AROUND an installed Adom-library part, so the project/schematic/2D-board/3D-board surfaces have something true to show for one component. |-| `kicad_model_check` | Read-only 3D model diagnostics for a footprint or a whole board: which (model ...) references exist, where KiCad looks, whether each file exists and holds geometry. |-| `kicad_pcm_add_repository` | Register a PCM repository in kicad.json (pcm.repositories), the way Plugin and Content Manager > Manage > add URL does. |-| `kicad_pcm_install` | Install or update a package from a KiCad PCM repository on disk, exactly as the Plugin and Content Manager would (no dialog). |-| `kicad_pcm_list` | Packages KiCad's PCM considers installed on this machine, each with updateAvailable from its own repository (read-only). |-| `kicad_pcm_remove_repository` | Drop a PCM repository from kicad.json pcm.repositories. Installed packages stay installed. |-| `kicad_pcm_uninstall` | Remove a PCM-installed package: its 3rdparty directories and its record. |-| `kicad_read_symbol` | *standard* → `kicad_sym_info` |-| `kicad_remove_symbol` | *standard* → `kicad_sym_delete` |-| `kicad_rescan_libraries` | Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort. |-| `kicad_search_library` | Search symbols and footprints across every registered library (project and global sym-lib-table / fp-lib-table, stock and PCM libraries included) by name, keyword and description; ranked hits with lib ids. |-| `kicad_sym_create` | Append a symbol to a .kicad_sym (KiCad 10) from a pin list: rectangle body sized to the pins and names, pins on the 2.54 mm grid by side, Reference/Value/Footprint/Datasheet/Description/keywords and custom fields, multi-unit when pins name units. |-| `kicad_sym_delete` | Delete one symbol from a .kicad_sym; refuses when other symbols extend it unless force:true. |-| `kicad_sym_info` | Full details of one symbol: every pin (number, name, electrical type, shape, position, length, unit, hidden), unit and body-style counts, fields, power flag, extends (resolved). |-| `kicad_uninstall_library` | Remove a library from the user's sym-/fp-lib-table: the inverse of kicad_install_library. Optionally deletes the copy the install made. |--**Placement and board edits** (33)--| Verb | What it does |-|---|---|-| `kicad_add_footprint` | Add a library footprint at x, y, rotation and side with a reference and value, a row or grid of them (count, pitch), or a copy of a placed one (duplicateOf); optional pad nets. |-| `kicad_add_mounting_hole` | Add a mounting hole footprint at x, y: NPTH by default, or plated with a pad (and a net); KiCad's MountingHole library footprint when one matches the drill, else a generated one. |-| `kicad_align_footprints` | Align footprints on a common left, right, top, bottom or centre line, or distribute them evenly, by courtyard box or anchor, through kicad_move_footprint's checked move. |-| `kicad_board_outline` | Draw the board outline on Edge.Cuts: a rectangle, a rounded rectangle with true arcs, or a polygon; replace:true removes the old outline first. |-| `kicad_board_pads` | Every pad in BOARD coordinates with its net: the input a router needs. |-| `kicad_check_clearance` | Measure the gap between footprints, courtyard to courtyard or pad copper to pad copper, for the listed refs or the whole board, and report every pair under a threshold, closest first. |-| `kicad_cross_probe` | From a schematic symbol (reference, uuid or sheet path) to the board footprint linked to it, and back, by the footprint's symbol path link. |-| `kicad_delete_footprint` | Delete footprints by reference, as one native Undo step live or one atomic write to a closed file. |-| `kicad_delete_graphics` | Delete board graphics (lines, rects, circles, arcs, polygons, curves, texts, text boxes, dimensions) by uuid, or every one on a layer and/or of a type. |-| `kicad_edit_footprint` | Edit placed footprints: value, reference, any field (add, change, remove), DNP, exclude from BOM or position files and other attributes, and the lock. |-| `kicad_flip_footprint` | Move footprints to the other side of the board, mirrored about their anchors: KiCad's own FlipItems live on 10.0.6+, a full client-side mirror otherwise (and on a closed file). |-| `kicad_footprint_graphics` | List a placed footprint's graphics (silkscreen, fab, courtyard lines, rects, circles, arcs, polygons, texts) and edit one by uuid: vertices, width, layer, fill, or delete it. |-| `kicad_footprint_models` | List, add, replace or remove the 3D model entries (path, offset, scale, rotation, visibility) of a placed footprint. |-| `kicad_get_selection` | What is selected in the PCB editor right now: ids, types, footprint references, nets and positions. |-| `kicad_import_svg` | Import an SVG logo or artwork as filled polygons on a silkscreen, copper, mask or user layer, at a position and size; curves flattened, holes kept. |-| `kicad_move_footprint` | Move one footprint, or a batch, live in the PCB editor as ONE native Undo step, after checking the revision, courtyard collisions and the board outline. |-| `kicad_place_decoupling` | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, power pad facing its pin), dryRun by default; apply moves them through kicad_move_footprint's live commit as one Undo step. |-| `kicad_placement_state` | Every footprint with its courtyard box, side, pose, pads and nets, the board outline, the nets each footprint touches and a ratsnest estimate: the input an AI needs to place a board. |-| `kicad_placement_validate` | Check the placement: courtyard overlaps between every footprint pair, footprints outside the outline or parked, the ratsnest total, and KiCad DRC filtered to placement-class violations. |-| `kicad_save` | *standard* → `kicad_save_board` |-| `kicad_save_board` | Save the board open in the PCB editor (File > Save through the IPC API) and confirm the file on disk matches the editor. |-| `kicad_score_placement` | Score a placement 0 to 100 with named deductions: courtyard overlaps, parts off the board, connectors away from the edge, decoupling caps far from their IC's power pin, and ratsnest length. |-| `kicad_select_clear` | Clear the live PCB editor's selection. |-| `kicad_select_items` | Set the PCB editor's selection to items by id and/or footprints by reference: replace (default), add or remove. |-| `kicad_select_net` | Select every copper item of one net on the live board (pours, tracks, vias, pads) so the editor highlights the net as a whole; no cursor, no hotkey. |-| `kicad_selection` | *standard* → `kicad_get_selection` |-| `kicad_set_layer_count` | Set the copper layer count (2, 4, 6 or 8) of a closed board: the layers table and, when the board has one, the physical stack-up (board thickness kept). |-| `kicad_silk_fields_batch` | Update native footprint reference/value field geometry by stable field UUID. |-| `kicad_silk_graphics_batch` | Create/update/remove native silkscreen lines, borders, arcs and Bezier leaders by stable ID. |-| `kicad_silk_text_batch` | Create, update and remove standalone silkscreen text by stable UUID with native Undo and DRC preflight. |-| `kicad_text_bounds` | Measure candidate silkscreen text using native KiCad text metrics without editing. |-| `kicad_update_footprints_from_library` | Compare placed footprints with their current library definition and, with apply:true, replace the ones that differ, keeping position, side, reference, value, fields, pad nets and the schematic link. |-| `kicad_update_pcb_from_schematic` | Compare the schematic's netlist with the board (missing, extra and changed footprints, changed values, pad net changes) and, on a closed board file, apply it: add missing footprints from the libraries in a row below the board, update values and links, assign pad nets, mark extras. |--**Routing, copper and rules** (26)--| Verb | What it does |-|---|---|-| `kicad_add_rule` | *standard* → `kicad_custom_rules` |-| `kicad_add_track` | Write one copper segment to a CLOSED board file. |-| `kicad_add_via` | Drop one via so a route can change layer. |-| `kicad_add_zone` | Add copper zones (pours) live as one Undo step: DRC preflight with the zone filled, then created and filled through the IPC API. |-| `kicad_apply_zone_plan` | Reconcile a complete explicit-owned zone plan as one native Undo step after full-candidate DRC. |-| `kicad_autoroute` | Route a board with the engine the USER picked: "ai" (recommended) returns the plan request you drive with kicad_route_net; "freerouting" runs Freerouting headless (installed on demand) and lands its copper live as one undo step per net, or into the closed file. |-| `kicad_board_rules` | Read or write the project's board constraints (Board Setup > Constraints: min clearance, track width, via, annular ring, hole sizes and spacing, copper to edge, silk, text) and the predefined track widths, via sizes and diff pairs in the .kicad_pro. |-| `kicad_copy_routing` | Copy the tracks and vias inside a source box to a target offset, optionally remapping nets by pairing footprints (refMap or a reference pattern), live with DRC preflight as ONE Undo step. |-| `kicad_custom_rules` | List, add, replace, remove and validate the named rules in the project's .kicad_dru (per-layer width or clearance, netclass pairs, any KiCad constraint). |-| `kicad_freerouting` | The Freerouting engine's lifecycle: status (disk facts only), install (88 MB download with its own Java, no elevation, only when asked), uninstall (removes the bridge's Freerouting folder and nothing else). |-| `kicad_list_design_rules` | List the fabs / processes / layer counts on adom/pcb-design-rules. |-| `kicad_modify_track` | Change the width, layer or endpoints of existing tracks by id, live in the PCB editor as ONE Undo step after a KiCad DRC preflight. |-| `kicad_net_classes` | *standard* → `kicad_netclasses` |-| `kicad_netclasses` | Read every netclass in the project (.kicad_pro) with its clearance, widths, via and diff pair sizes, its patterns and the board nets they match; create or update a class, assign nets by pattern, remove a class. |-| `kicad_plan_fanout` | Plan the fanout of a BGA or QFN/QFP: detect the pad pitch, pick dogbone or via-in-pad per ball, the escape layer, surface stubs for the outer ring, a via grid for an exposed pad, returned as kicad_route_net-ready calls. |-| `kicad_refill_zones` | Refill every copper zone (or the listed ones when KiCad accepts a list) in the open PCB editor, wait for the fill to settle, and report the filled copper per zone and layer. |-| `kicad_remove_route` | Remove specific unlocked copper item IDs live as one Undo step. |-| `kicad_remove_zone` | Remove zones (pours or keepouts) by id live as one Undo step. |-| `kicad_repour` | *standard* → `kicad_refill_zones` |-| `kicad_route` | Lay a whole path for one net in a single write, vias included. |-| `kicad_route_diff_pair` | Route a differential pair as two coupled parallel paths offset from one centerline (gap, width, layer, vias placed as pairs), each half through kicad_route_net's planner, DRC preflight, ONE Undo step, with the length skew reported. |-| `kicad_route_net` | Commit a waypoint path or branching paths for ONE net live with native Undo and DRC preflight. |-| `kicad_route_pair` | *standard* → `kicad_route_diff_pair` |-| `kicad_routing_validate` | Run KiCad DRC against the current unsaved live board snapshot. |-| `kicad_set_design_rules` | Stage a fab's .kicad_dru next to a board and run DRC with it. |-| `kicad_zone_state` | Read every zone on the live board with KiCad's filled copper area per layer and the coverage. |--**Checks, exports and manufacturing** (29)--| Verb | What it does |-|---|---|-| `kicad_errors` | Every KiCad error/warning DIALOG on screen, with its exact text (read-only, background). |-| `kicad_estimate_cost` | The board's numbers in the shape a quote tool needs (size, layers, thickness, min track/space/drill, holes, SMD/THT parts and joints, unique parts) plus a rough JLCPCB-style price table with every assumption stated. |-| `kicad_export_3d` | Export the board's 3D model through kicad-cli in a format other than STEP: stl, glb, vrml, ply, brep, xao, u3d, stpz or 3dpdf (the standard's export_3d). obj, 3mf, iges and other formats KiCad cannot write are refused by name. |-| `kicad_export_bom` | Export the project's bill of materials as a CSV through kicad-cli from its root schematic (the standard's export_bom). |-| `kicad_export_bom_csv` | Export a BOM CSV from a schematic via kicad-cli. |-| `kicad_export_dxf` | Export board layers to DXF via kicad-cli, one .dxf per layer (mechanical outline, panel drawings, laser stencils). |-| `kicad_export_fab_package` | One-call fab package: Gerbers and drill, a BOM grouped from the board (value + footprint + MPN), the CPL, a README listing every file, all zipped. fab:"jlcpcb" writes JLCPCB's BOM and CPL columns (LCSC part field included). |-| `kicad_export_gencad` | Export the board as GenCAD 1.4 via kicad-cli (test-fixture and some assembly houses' CAM input). |-| `kicad_export_gerber` | Export Gerbers AND Excellon drill files (plated and non-plated separate, with drill maps) from a board via kicad-cli: a complete fab set in one call. |-| `kicad_export_ipc2581` | Export the board as IPC-2581 (one XML with copper, drill, stackup, netlist and BOM) via kicad-cli. |-| `kicad_export_ipc356` | Export the IPC-D-356 netlist (bare-board electrical test) via kicad-cli. |-| `kicad_export_library_part` | *standard* → `kicad_export_part` |-| `kicad_export_manufacturing` | *standard* → `kicad_export_fab_package` |-| `kicad_export_molecule` | One-shot molecule export pack: STEP + silk renders + sch/pro discovery for the Adom molecule pipeline. |-| `kicad_export_odb` | Export the board as ODB++ via kicad-cli (zip by default). |-| `kicad_export_pdf` | Plot a board or schematic to PDF via kicad-cli. |-| `kicad_export_pos` | Export the pick-and-place (component position / CPL) file from a board via kicad-cli, optionally reformatted for a fab's assembly service. |-| `kicad_export_spice` | Export a schematic's SPICE netlist with KiCad's own exporter (kicad-cli), headless, and say what it holds: its elements, the analyses the schematic carries, and the model files it includes. |-| `kicad_export_step` | Export a STEP model of the board via kicad-cli. |-| `kicad_export_svg` | Plot a board or schematic to SVG via kicad-cli. |-| `kicad_fab_check` | Will a fab make this board? Its smallest features (track, clearance rule, via and pad holes and rings, SMD pads, edge clearance, hole spacing, silkscreen text and lines, layers, size) measured and compared with a fab's limits from adom/pcb-design-rules, each finding naming the fab's page and the date it was read; or with limits you give. |-| `kicad_format_upgrade` | Upgrade a file to the current KiCad file format. |-| `kicad_lint_board` | Lint a .kicad_pcb (DRC pre-check) via kicad-cli. |-| `kicad_lint_schematic` | Lint a .kicad_sch (ERC pre-check) via kicad-cli. |-| `kicad_render_board` | Render a 3D image (PNG/JPG) of a board from a side via kicad-cli pcb render, headless, no KiCad window. |-| `kicad_run_drc` | Run kicad-cli pcb drc on a board and return the parsed JSON report (violations, unconnected, counts by severity). |-| `kicad_run_erc` | Run kicad-cli sch erc on a schematic and return the parsed JSON report. |-| `kicad_run_script` | Run a Python script of your own with KiCad's bundled Python, where `import pcbnew` works, headless; its output comes back. |-| `kicad_validate_for_fab` | Manufacturability pre-flight: closed outline, layer count, min track, space, drill and ring vs a fab profile, copper near the edge, silkscreen on pads, vias in pads, acid traps, footprints without 3D models, fiducials (assembly), plus KiCad DRC errors and unconnected items. Pass or fail with findings. |--**Projects** (4)--| Verb | What it does |-|---|---|-| `kicad_project_create` | Create a new KiCad 10 project folder: <name>.kicad_pro, a blank .kicad_sch and a blank .kicad_pcb (2 to 32 copper layers), optional title and text variables; a fab rules name is recorded to stage later. |-| `kicad_project_info` | A project at a glance: name, files with sizes and dates, the schematic sheet tree, the board (layers, footprints, nets, size), text variables, net classes, last modified, and KiCad lock files. |-| `kicad_project_rename` | Rename a project: the .kicad_pro, root .kicad_sch, .kicad_pcb, .kicad_prl (and .kicad_dru, backups folder), with the references inside updated (project file entries, every schematic's instance headers, the board's sheet references and title). Refused while KiCad has the project open. |-| `kicad_project_snapshot` | Timestamped copy of the project's files into <project>/snapshots/<stamp>[-label]/, plus schematic and board PDFs made with kicad-cli. |--**Windows, editors, install and the bridge** (159)--| Verb | What it does |-|---|---|-| `kicad_3d_view` | Drive the 3D Viewer without the mouse: zoom, rotate, view orientation, flip, or a scripted tour; menu commands posted to the viewer in the background. |-| `kicad_3d_viewer_settings` | Read or set the 3D viewer's model-visibility toggles (SMD, through-hole, virtual, not-in-POS, DNP) that can hide every chip body. |-| `kicad_activate_document` | Make a board or schematic the current one, in the background (the standard's activate_document): KiCad's verbs take the file's path, and a board opened here is the one the PCB editor's API serves. |-| `kicad_add_body` | Put a 3D body (a STEP or VRML model) on a library footprint. It REPLACES the footprint's models unless keepExisting:true, so a re-import never stacks a second body. |-| `kicad_add_symbol_art` | Draw artwork (polylines: a chip outline, a logo) inside a library symbol's body rectangle, scaled to fit with a margin and centred, keeping its aspect; the art comes as point lists or from another symbol's polylines (adom-symbol's baked chip outline). |-| `kicad_agent_recover` | Recover a halted script agent (the standard's agent_recover). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover. |-| `kicad_agent_start` | Start the tool's resident script agent (the standard's agent_start). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover. |-| `kicad_agent_status` | Report the tool's resident script agent (the standard's agent_status). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover. |-| `kicad_agent_stop` | Stop the tool's resident script agent (the standard's agent_stop). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover. |-| `kicad_align_parts` | *standard* → `kicad_align_footprints` |-| `kicad_analyze_connections` | Connectivity overview: suspected-unconnected pins, power nets, highest-fanout nets/components (read-only). |-| `kicad_animate_view` | Camera choreography on the open board, run inside the bridge so the timing is tight enough to narrate over: zooms on the PCB editor (KiCad's own actions through its API), and in the 3D Viewer orbits, rolls, views, pans and flips (its View-menu commands), all in the background. |-| `kicad_assembly_check` | Ready for JLCPCB assembly? The board against both JLCPCB PCBA tiers as published (Economic and Standard): board or panel size, thickness and layers, parts on one or both sides, the smallest package and the finest pin pitch, fiducials, and the purchased parts with no LCSC number; answers the cheapest tier every rule allows. |-| `kicad_auto_classes` | Net classes from the nets' own names: GND, Power (VCC, VDD, 3V3, VBAT...), Clock and HighSpeed (USB, Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their names spell; a proposal first, written into the project's net classes with apply. |-| `kicad_board_report` | One shareable page per board: every check run against the board and its project (KiCad's DRC and ERC, a fab's limits, the board against the schematic, symbols against footprints, return paths, copper balance, impedance widths, 3D heights, pair skew and part orientation) with a picture of the board, written as a single HTML file. |-| `kicad_bom` | The project's bill of materials: every schematic part (fields, DNP, exclude from BOM) merged with the board (footprint, position, side), one line per manufacturer part number, checked for what an order needs, and written as bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv, bom-mouser.csv and bom-digikey.csv (+ bom.json). |-| `kicad_bridge_call` | Retired: there is no in-KiCad plugin to call. Use the verb that does the job directly. |-| `kicad_bridge_status` | Retired: the in-KiCad Python plugin (reverse bridge) is not part of the native bridge. Reports that, plus what replaced each of its jobs. |-| `kicad_build_board` | Build a NEW board from a parts list and a netlist: the outline (a rectangle with rounded corners allowed, or a polygon), every footprint from its library placed by its own origin, pad nets assigned, saved; then read back pin by pin. |-| `kicad_build_schematic` | Draw a design's schematic from its netlist with library symbols, as a new sheet (and project): one column per block, every part placed, a wire stub and a net label on every connected pin, the title block filled; then KiCad's netlist of it checked against the nets given, pin by pin, and ERC run. |-| `kicad_build_symbol_modes` | Give a library symbol its De Morgan alternate body (KiCad's second display mode): the alternate drawing for every unit, the standard body's pins copied with any pin shapes changed (inverted inputs), and the symbol marked as having two body styles. |-| `kicad_check_for_updates` | Read-only: installed KiCad vs latest stable. |-| `kicad_check_orientation` | Advisory: two-pad parts whose rotation could be better. Each part is scored at 0, 90, 180 and 270 degrees by airline length (a net's spanning tree, or for a plane-like net the distance to its nearest pad); parts whose best turn saves minImprovementMm are listed. Nothing is moved. |-| `kicad_cleanup_libs` | Clean a symbol or footprint library: the parts no given project uses, and parts that repeat another part under a different name (same pins and graphics, or same pads and graphics). A plan by default; apply removes the names you list. |-| `kicad_click` | Click at coordinates inside a KiCad window. |-| `kicad_clone_symbol` | Copy a symbol inside a .kicad_sym under a new name, every unit and body style included. |-| `kicad_close` | Close KiCad (editors first, then the project manager). |-| `kicad_close_3d_viewer` | Close the 3D viewer window. |-| `kicad_close_footprint_editor` | Close the Footprint Editor window. |-| `kicad_close_symbol_editor` | Close the Symbol Editor window. |-| `kicad_compile` | Check a project's schematic the way a compile does: KiCad's ERC on the root sheet (hierarchy followed) and the netlist, with the messages counted by class and the net and part counts. |-| `kicad_component_classes` | List, make or remove component classes on the board (KiCad 9+), each optionally with a room: a rule area that keeps the class's parts together (fitted around their pads or a rectangle), enforced by a custom DRC rule. The classes scope rules (A.hasComponentClass('<name>')) and group a channel for kicad_replicate_layout. |-| `kicad_connect_workspace` | Connect the tool to a cloud workspace (the standard's connect_workspace). Not in KiCad: KiCad has no cloud workspace to connect to. |-| `kicad_copper_balance` | Copper per layer as a share of the board, poured copper included, and how evenly mirrored layers match (F.Cu against B.Cu, In1 against the last inner layer): a pair that differs by more than 20 points can bow or twist through reflow. |-| `kicad_copy_footprint` | Duplicate a footprint inside a .pretty library under a new name (every pad, graphic, text and 3D model copied). |-| `kicad_create_part_project` | *standard* → `kicad_make_part_project` |-| `kicad_cross_check` | Compare the schematic's designators with the board's (the standard's cross_check, computed by its engine): parts on the sheets but not on the board, and parts on the board but not on any sheet. |-| `kicad_current_capacity` | Whether each named net's copper carries its current: per layer, the narrowest track against the width IPC-2221 needs for the temperature rise, and how many vias a layer change needs. |-| `kicad_demo` | Six-beat KiCad showcase tour: staged, or a background job with structured progress (percent/ETA), an ab demo-panel remote, captions, narration, per-beat verification and a kicad_state final payload. |-| `kicad_describe` | List every verb with input, example, hint, related verbs and pitfalls. |-| `kicad_diagnostics` | Environment the bridge runs in: user, paths, env vars that matter, KiCad config dir. |-| `kicad_dialog_log` | What the bridge's dialog sweeps pressed since it started, newest last: each KiCad dialog it dismissed (benign notices, error boxes after a verb), with its title, body, the button and when. |-| `kicad_diff_pairs` | Differential pairs on the board: list the pairs KiCad's router recognises from net names (+/-, P/N, _P/_N), with the net class that sets their width and gap; define one (a net class holding both nets, with diffPairWidth and diffPairGap) or remove that class. |-| `kicad_dismiss_dialogs` | Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog). |-| `kicad_doc_params` | Read or set a sheet's document parameters: KiCad's title block (Title, Revision, Date, Company, Comment1 to Comment9). |-| `kicad_document_info` | The active KiCad document (the topmost KiCad window): its name, kind (board, schematic, library, project, model), whether it has unsaved changes, where it is saved, and its project (the standard's document_info). Read only; opens nothing. |-| `kicad_edit_parts` | *standard* → `kicad_edit_footprint` |-| `kicad_edit_pours` | Change existing copper pours (zones) in place without redrawing them: clearance, minimum width, thermal reliefs (on/off, spoke width, gap) or the pad connection, priority, the net, and whether islands are kept; then refill (the standard's edit_pours). |-| `kicad_enable_software_opengl` | LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively. |-| `kicad_eval` | Run a Python snippet with KiCad's pcbnew module, headless: with filePath the board is loaded as `board`; set `result` to return a value as JSON. |-| `kicad_extension_ping` | Ping the bridge's extension inside the tool (the standard's extension_ping). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for. |-| `kicad_extract_netlist` | Extract the full netlist from a schematic: every component and every net with its nodes (read-only, via kicad-cli). |-| `kicad_find_connections` | Everything one component connects to, pin by pin, with peer pins per net (read-only). |-| `kicad_find_space` | Free spots on the board for a part near another: clear of every other part's courtyard on that side, of that side's tracks and vias, and inside the outline, nearest first. check:true lists parts that already overlap or leave the outline. |-| `kicad_fix_keyboard` | Reset stuck modifier keys on a KiCad window. |-| `kicad_footprint_from_land_pattern` | Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard, silkscreen kept clear of the copper with a pin-1 dot, a fab outline with a pin-1 chamfer, and a 3D body box when the height is given. |-| `kicad_footprint_primitives` | Geometry of one PLACED part on the board (the standard's footprint_primitives): its graphic lines and arcs, pads (shape, size, hole, net, solder and paste mask expansion in um) and texts, in mils in KiCad's board frame with each item's layer; other kinds are counted. |-| `kicad_foreground` | Bring a KiCad window in front of the user: the one sanctioned exception to the never-foreground rule. A reason written for the person is required; the bridge captions it first. |-| `kicad_generate_body` | Make a part's 3D body from its package family and datasheet dimensions (chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, electrolytic can, crystal, pin header and others), to IPC-7351 proportions, optionally with a marking on its top; written as a STEP file and, given a footprint, attached to it. |-| `kicad_get_settings` | Read the bridge's user-visible settings (overlay badges, ...). |-| `kicad_height_check` | Will it fit the enclosure? Each part's height from its 3D model (top and bottom), the parts over a height limit board-wide or under a lid feature in a region, and bodies that collide or come closer than a gap. |-| `kicad_impedance` | The track width for a target impedance (and the pair width and gap for a differential target) on each copper layer, from the board's own stack-up; optionally the width or pair rules for kicad_custom_rules. |-| `kicad_import_design` | Bring a board from another tool into a NEW KiCad project through kicad-cli's importer: eagle, altium, pads, cadstar, fabmaster, pcad or solidworks (the standard's import_design). The source files are never changed. |-| `kicad_import_step` | Place a STEP as free 3D geometry with the board (the standard's import_step). Not in KiCad: KiCad places 3D models only as a footprint's own model. |-| `kicad_ipc_api` | Read or flip KiCad's IPC API server switch (Preferences > Plugins) that live routing needs. |-| `kicad_jlcpcb_order` | A JLCPCB order in one call: the board checked against JLCPCB's fab limits, the Gerbers and drill zipped the way JLCPCB's order page takes them, the JLCPCB BOM and pick-and-place (CPL), the assembly-tier check and the order-form values, in one folder with every issue in one list. |-| `kicad_launch` | Cold-start KiCad (project manager) when nothing is running. |-| `kicad_layers` | The board's whole layer set: KiCad's id, name, kind (signal, power, mixed, user) and the user's name for each, and whether the open PCB editor shows it. |-| `kicad_length_tune` | Lengthen a routed net to a target length, or to match another net (a pair's skew, a DDR byte lane): a serpentine in its longest straight track that has room beside it, the bumps sized to add exactly the length missing, every bump clear of other copper and inside the board. Live as one Undo step after a DRC check, else in the closed file. |-| `kicad_library_views` | Pictures of library parts for review, each as an image file: the schematic symbol, the footprint (pads with their numbers), the 3D body seated on the footprint, and the device view (every symbol pin mapped to a pad). |-| `kicad_license` | Drive the tool's licence page (the standard's license). Not in KiCad: KiCad is GPL-3.0-or-later and has no licence page to drive. |-| `kicad_license_status` | What licence KiCad is on, in plain words: KiCad is free, open-source software (GPL-3.0-or-later), so there is no seat, sign-in or trial; with the installed version. |-| `kicad_list_installed_libraries` | Every symbol and footprint library KiCad loads: the global tables and, with projectPath, the project's own, with nickname, path and whether the path exists. |-| `kicad_list_library` | Names of every symbol in a .kicad_sym or every footprint in a .pretty folder, the kind taken from the path (the standard's list_library). |-| `kicad_list_versions` | Every KiCad install found (registry and standard folders) with bin dir, kicad-cli path and version. |-| `kicad_log_tail` | New KiCad log output since a byte offset: what KiCad said about ITSELF (read-only). |-| `kicad_menu_items` | List a KiCad window's menu bar items (path, id, accelerator), optionally filtered by a substring: what kicad_send_key {menu} and kicad_3d_view can fire. |-| `kicad_net_connections` | Every pad on the board with its net and owner (U1.3), and per net its span and airline length: the minimum spanning tree over its pads, the shortest copper a router could need (the standard's net_connections). |-| `kicad_net_lengths` | Routed length of every net (or the ones named) by layer, with its vias; the skew inside each differential pair; and the spread of matched-length groups against a tolerance. |-| `kicad_normalize_bodies` | Move each 3D model of a footprint library (or one footprint) onto its pad cluster when its body sits more than minShiftMm from the pads' centre, by its offset; models already on their pads are left alone. |-| `kicad_open_3d_viewer` | Open the 3D board viewer for a .kicad_pcb. |-| `kicad_open_board` | Open a .kicad_pcb in the PCB editor (pcbnew). |-| `kicad_open_document` | Open a board, schematic or project by path in KiCad without taking the foreground (the standard's open_document): .kicad_pcb in the PCB editor, .kicad_sch in the Schematic Editor, .kicad_pro in the project manager. |-| `kicad_open_editors` | List currently open KiCad editor windows. |-| `kicad_open_footprint_editor` | Open the Footprint Editor (optionally at a footprint). |-| `kicad_open_project` | Open a KiCad project (.kicad_pro) in the project manager without taking the foreground (the standard's open_project). |-| `kicad_open_schematic` | Open a .kicad_sch in the schematic editor (eeschema). |-| `kicad_open_symbol_editor` | Open the Symbol Editor (optionally at a symbol). |-| `kicad_panelize` | Step a rectangular board into a manufacturing panel: rows x cols copies, each copy's nets renamed B<n>-<net>, rails, three tooling holes and three fiducials; separated by V-score (copies abutting, V-cut lines on Cmts.User) or mouse-bite tabs (copies a gap apart, the rout path on Edge.Cuts broken at the tabs, five 0.6 mm holes a tab), with JLCPCB's panel rules checked; written as a new board and checked with KiCad's DRC. |-| `kicad_part_parameters` | The parameter (field) names a project's placed parts carry, with how many parts carry each; or, with name, that parameter's value on every part. Read only. |-| `kicad_pin_pad_check` | Do the footprints fit their symbols? Part by part, the symbol's pin numbers (every pin, from the schematic's netlist) against the footprint's pad numbers on the board: pins with no pad, pads with no pin, and parts only on the sheets or only on the board. |-| `kicad_place_footprint` | Deterministically place a footprint into a board preview. |-| `kicad_place_on_sch` | Place one symbol or a ';' list of symbols from a .kicad_sym on ONE new sheet, sized from the measured parts (12.7 mm gaps, 15.24 mm margins, A4 at least), each with the next free designator and its name as the value; fills the title block. |-| `kicad_placement_check` | A placement review before routing: parts whose pads and 3D bodies come closer than a margin on the same side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a mounting hole. |-| `kicad_plan_route` | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed copper layers, with vias where they help. Answered as kicad_route_net's own arguments. |-| `kicad_plate_vias` | Make every drilled pad in a footprint library a plated hole through every copper layer (thru_hole, *.Cu *.Mask): a thru_hole pad on only some copper layers, or an smd pad with a drill (thermal vias written as surface pads). Numbered unplated holes are listed, and plated only with includeNpth. |-| `kicad_plugin_diagnose` | Retired: reports that no plugin is expected and whether a leftover Python plugin payload is still on disk. |-| `kicad_prewarm` | Get this machine ready for a fast first KiCad call: detect KiCad, seed its first-run config, mark its setup wizard done, repair its taskbar shortcuts. Idempotent. |-| `kicad_progress` | What this bridge is doing RIGHT NOW: one progress block per in-flight phase for THIS caller (stepLabel, percent, etaSec, confidence, blockedBy, stepShot), plus the machine's timing history. |-| `kicad_read_footprint` | *standard* → `kicad_fp_info` |-| `kicad_read_sheet_file` | Read a schematic sheet straight from its .kicad_sch file, with no KiCad running: every placed part unit with its designator, library symbol, value, description, uuid, unit and unit count, position, rotation, flags, and optionally its fields and pins. |-| `kicad_readiness` | Is KiCad installed and usable here; if not, the three alternatives (service-kicad, kicad-cli in your container, another box). |-| `kicad_refresh_board_views` | Refresh the exact PCB editor and its already-open linked 3D viewer without focus or mouse. |-| `kicad_remove_footprint` | Delete one footprint from a .pretty library (a library file, not a part on the board: that is kicad_remove_parts). |-| `kicad_remove_graphics` | *standard* → `kicad_delete_graphics` |-| `kicad_remove_items` | Delete specific free tracks, arcs and vias given by their geometry, exactly as kicad_routing_state {model:"standard"} reported them, as one Undo step (the standard's remove_items). |-| `kicad_remove_parts` | *standard* → `kicad_delete_footprint` |-| `kicad_rename_footprint` | Rename a footprint in a .pretty library: the file and the name inside it. |-| `kicad_rename_symbol` | Rename a symbol in a .kicad_sym (its units too); symbols that extend it follow the new name. |-| `kicad_render_board_3d` | *standard* → `kicad_render_board` |-| `kicad_render_board_layers` | Render chosen board layers to a PNG or SVG through kicad-cli without opening a window (the standard's render_board_layers): a preset view (copper, silkscreen, solder mask, paste, outline, drill, assembly, fabrication, either side) or a list of layers. |-| `kicad_replicate_layout` | Copy one channel's placement to matching channels: each target keeps its anchor part where it is, and every other part takes the source's offset and rotation from the anchor (turned by the anchors' rotation difference). One Undo step, with kicad_move_footprint's checks. |-| `kicad_return_path` | Signal tracks that run over a gap in the reference copper (pours and planes) on their neighbouring layers, with how far and where the gap starts, and a ready kicad_apply_zone_plan call that pours the reference net under each gap. |-| `kicad_review_board` | Design review of the whole board in one read (with the schematic's parts from KiCad's netlist when the project has one): duplicate designators, parts off the board or near its edge, placeholder values on the board and the sheets, one-pin nets, parts without a footprint, missing vendor part numbers and datasheets, and disabled or missing design rules, each finding with its severity. |-| `kicad_ripup_nets` | Delete every unlocked track, arc and via of the named nets live, as one Undo step (pads, pours and locked copper stay). |-| `kicad_routing_drc` | KiCad's DRC on the board as the PCB editor holds it (unsaved edits included; the file when no editor serves it), with the project's rules and zones refilled: one clean or not-clean answer with every violation's place (the standard's routing_drc). |-| `kicad_rules_by_kind` | Every design rule the board answers to, grouped by kind (Clearance, Width, RoutingVias, HoleSize, ...) and sorted by priority (1 wins), from the project's custom rules (.kicad_dru), net classes and Board Setup minimums (the standard's rules_by_kind). |-| `kicad_schematic_review` | Review the schematic before updating the board: every pin (with its electrical type) against KiCad's own netlist of the design. Errors: power pins on no net, outputs driving each other. Warnings: pins on no net, one-pin nets, inputs nothing drives, IC supplies with no decoupling capacitor to ground. |-| `kicad_screenshot_all` | Screenshot every open KiCad window. |-| `kicad_selftest_script_error` | Self-test the script-error safety net (the standard's selftest_script_error). Not in KiCad: KiCad runs no resident script engine to test: a failing kicad_eval snippet already comes back as eval_failed with its traceback. |-| `kicad_send_key` | Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background) |-| `kicad_set_layer_visibility` | Show or hide one layer in the open PCB editor (KiCad's own layer visibility, through its API), read back. |-| `kicad_set_params` | Set parameters on a library symbol (fields; a missing one is added hidden), optionally its value (the standard's comment) and its footprint link. |-| `kicad_set_settings` | Change bridge settings; applies immediately (e.g. overlay badges stamped/cleared on live windows). |-| `kicad_set_track_width` | Set the width of the EXISTING tracks and arcs on nets or a net class, optionally on one layer (the standard's set_track_width): widen power nets after routing, or thin a net that fails clearance. Net-class rules only steer new routing. |-| `kicad_set_visibility` | Show or hide one field of a library symbol. |-| `kicad_sheet_params` | Write parameters onto PLACED parts on the schematic sheets (the BOM write-back: MPN, Manufacturer, Mouser/DigiKey/LCSC numbers, anything): every unit of each designator, on whichever sheet of the project holds it. A missing parameter is added hidden. |-| `kicad_show_2d_board` | Show the 2D board layout in the PCB Editor. |-| `kicad_show_3d_board` | Show the whole board in 3D (the board's 3D viewer). |-| `kicad_show_3d_chip` | Show a single part in 3D (the footprint's 3D view). |-| `kicad_show_footprint` | Show a footprint in the Footprint Editor. |-| `kicad_show_library` | Browse a symbol or footprint library (no part loaded). |-| `kicad_show_project` | Show the whole electronics PROJECT: the KiCad Project Manager (top of the design tree). |-| `kicad_show_schematic` | Show a schematic (2D) in the Schematic Editor. |-| `kicad_show_symbol` | Show a schematic symbol in the Symbol Editor (design/library surface). |-| `kicad_signin` | Sign in to the tool's account (the standard's signin). Not in KiCad: KiCad has no account to sign in to. |-| `kicad_silk_designators` | Place every reference designator the way assembly houses want: one size, upright, beside its own part, clear of every pad (JLCPCB: 0.15 mm), every other part's body and the other designators, inside the board, then read back from KiCad. Parts with no clear spot are reported, not forced onto pads. |-| `kicad_silk_state` | The silkscreen as it stands: every part's body box, its designator and value as placed (text, position, size, rotation, shown), and the free texts and graphics on both silkscreen layers. |-| `kicad_silkscreen_prep` | Get the silkscreen off the copper for fab: the Gerbers set to subtract the solder mask from the silkscreen (KiCad's own clipping), silkscreen lying outside the board deleted, designators that sit on pads moved beside their parts, and KiCad's silkscreen DRC findings counted before and after. |-| `kicad_simulate` | Simulate a schematic with SPICE, headless: KiCad's netlist of the sheet run by the ngspice library KiCad installs, with the analysis you give (transient, AC, DC sweep, operating point) or the one the schematic carries, optionally stepped over a part's value, a model parameter or the temperature; each probe comes back downsampled with its first, last, min and max values (AC as dB and phase), and as a CSV file. |-| `kicad_state` | KiCad's full state of the union: every owned window as inline base64 PNG + logical state. |-| `kicad_status` | Bridge health, KiCad version, install location and uptime. |-| `kicad_swap_footprint` | Put another library footprint on one placed part at the same position, rotation and side, keeping its reference, fields, schematic link, DNP/BOM flags and lock; pads keep their nets by number and assigns sets nets on any pad. Tracks are not moved. |-| `kicad_symbol_mode` | Show one body style of a library symbol as a picture: mode 0 the standard body, mode 1 the De Morgan alternate (KiCad's two display modes), for one unit, plotted by KiCad itself (kicad-cli) without opening an editor. |-| `kicad_symbol_prims` | A library symbol's drawing: primitives counted by kind, the body rectangle, the polylines' bounding box, units and body styles, the footprint link and filters. Read only. |-| `kicad_taskbar_repair` | Fix the white taskbar icon on standalone KiCad editors by writing KiCad's own AUMID onto its Start Menu shortcuts. |-| `kicad_teardrops` | Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after. |-| `kicad_test_board` | Place one footprint, or a ';' list, from a library on ONE board: a NEW board (wMm x hMm outline) when pcbPath does not exist, APPENDED right of the parts already there when it does; optionally pad nets on the last part. |-| `kicad_thermal_vias` | A thermal via grid in a part's exposed pad (the ground or thermal pad under a QFN, DFN, PowerPAD or regulator): centred, inset from the pad's edge, on the pad's net, sized by the project's Default net class. Live as one Undo step after a DRC preflight, else written into the closed file. |-| `kicad_tool_update` | KiCad's own updates (the standard's tool_update): check whether a newer stable KiCad is offered, apply it with the official installer when KiCad is closed, or read or set KiCad's own automatic update check. |-| `kicad_trace_net` | Every pin on one named net (read-only). |-| `kicad_uninstall` | Remove everything this bridge created: plugin payload, Adom symbol/footprint/3D libraries and their lib-table rows, Mesa fallback DLLs, discovery files, demo and staging projects. Never KiCad itself, never user projects. |-| `kicad_upgrade` | Install/upgrade KiCad via the official installer (agent installs for the user). |-| `kicad_variants` | KiCad 10's design variants (which parts are fitted on which build): list every variant with its not-fitted parts and changed fields, make or change one with not-fitted parts (or put parts back), or remove one, on the project's schematic files; KiCad's own netlist for the variant reads the result back. |-| `kicad_verb_times` | Measured per-verb run times ON THIS MACHINE: count, p50, p90, max, last, failures from the append-only run log, sorted by p90 descending. |-| `kicad_via_fence` | A via fence for EMC: a row of ground vias on both sides of RF or fast nets' tracks, and/or round the board edge, at a pitch from the frequency (lambda/20 in FR-4) or given; each via clear of other copper and pads, other vias, via keepouts and the edge. Live as one Undo step after a DRC preflight, else written into the closed file. |-| `kicad_window_info` | Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs). |-| `kicad_xcopy_footprint` | Copy a footprint ACROSS libraries: from fromLib into toLib (created when missing), optionally under a new name. |-| `kicad_zoom_to` | Frame the open PCB editor's view on parts or a whole net (or the whole board), with KiCad's own Zoom to Selected Objects run through its API, so it works while KiCad stays in the background. |--<!-- verbs:end -->---## Running / dependencies--Bridge provisions everything; there is nothing to install by hand. - **Python** ≥ 3.11 (Bridge provisions it), stdlib only, plus the bundled `certs/cacert.pem`. - **KiCad** ≥ 7.0 (host app; `kicad_upgrade` installs it if absent).-- **OS:** Windows and Linux (x86_64, Wayland or X11; see [KiCad on Linux](docs/linux.md)). A macOS build is in progress.+- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first). The bridge binds `ADOM_BIND_HOST` on a Bridge-assigned port, never `0.0.0.0`. Auto-updates via-`updateManifestUrl` in `bridge.json`.---*Developer docs ship as `user-invocable:false` skills in the package itself, `kicad-bridge-dev`-(architecture + hard-won findings), `kicad-bridge-publish` (release recipe), and `kicad-bridge-hero`-(page hero recipe). Maintainers get them with the normal `pkg install`; everyday users are never-offered them.*--## Contribute--This page's repo is the full source (`adom-wiki repo clone adom/kicad-bridge`): the Rust bridge, the skills, the docs and the release tooling. Hit a bug or a missing verb? File it with the verbatim reply, and if you fix it, send a branch PR off the current head; Adom merges PRs within minutes during business hours and ships to the insiders tier the same day. The recipe, the build commands and the rules that keep a PR mergeable are in the [kicad-bridge-contributing](skills/kicad-bridge-contributing/SKILL.md) skill and in [CONTRIBUTING.md](CONTRIBUTING.md).+`updateManifestUrl` in `bridge.json`.
RELEASE_NOTES.md+1−1100@@ -1,1105 +1,6 @@-# kicad-bridge release notes--Newest first. Written at publish time by tools/build_release.py, which refuses to-publish without one.--**Backfilled below 0.9.273 (2026-09-02).** Releases 0.9.261-0.9.272 shipped with-no usable changelog: build_release.py never passed `--changelog`, so the server-synthesised "- Update N file(s)" from repo-push commits that were themselves-unnamed. These entries were reconstructed after the fact from the work they contain, which is exactly the situation the mandatory-notes gate now prevents. Treat them as accurate but secondhand. -## Next release: KiCad on Linux (draft; version and date set at publish)--NATIVE (Rust) build. The Linux build drives KiCad's windows, menus and dialogs like the Windows build, on Wayland and X11 desktops. Guide: docs/linux.md.--- **Windows on Linux:** KiCad 10 runs as an X11 application everywhere, under XWayland on a Wayland desktop. The bridge reads its windows from the X server:- - `kicad_state`, `kicad_window_info`, `kicad_open_editors` and `kicad_document_info` list every KiCad window with its kind, process, owner, size and state;- - close, placement, the background guard and the 3D viewer verbs work;- - `kicad_readiness.platform` now says `windowControl: true` and gives the `session`, `wayland` or `x11`.-- **Screenshots on Linux:** `kicad_screenshot_all`, `kicad_state` and dialog evidence read each window's own pixels, so nothing moves and nothing takes focus (`capturedBy: "local:x11"`, PNGs in the bridge's data folder). Windows still captures through Adom Bridge.-- **Menus and dialogs on Linux:** the accessibility bus (AT-SPI) is used where Windows uses UI Automation:- - `kicad_menu_items` and menu invocation;- - chords through `kicad_send_key` (`alt+3` opens the 3D viewer in the background);- - dialog text, buttons and check boxes, and the automatic dialog sweep.-- **Symbol and footprint editors on Linux:** `kicad_open_symbol_editor`, `kicad_show_symbol`, `kicad_open_footprint_editor` and `kicad_show_footprint` load the part through the library tree's search field and its selected row (accessibility bus), in the background.-- **Simulation on Linux:** `kicad_simulate` runs ngspice through the distribution's libngspice; before, it answered that it needed Windows.-- **KiCad in its own scope on Linux:** each KiCad the bridge starts runs in its own systemd scope. Before, an out-of-memory KiCad took Adom Bridge down with it, because it ran inside Adom Bridge's service.-- **Opens in the background on Linux:** KiCad no longer takes keyboard focus from the window you are typing in when the bridge starts it (it did on GNOME Wayland).-- **No setup wizard on a fresh KiCad 10:** before the first launch the bridge writes any missing global library table, including KiCad 10's new `design-block-lib-table`. Without that table KiCad 10 opens its modal "KiCad Setup" wizard on every start; a distribution's KiCad package may not ship the file. Existing tables are never touched. `kicad_prewarm` does the same, and on Linux skips the Windows-only taskbar step instead of failing.-- **Fixes on Linux:**- - a KiCad on a merged-/usr distribution is listed once, not twice (`/bin` and `/usr/bin`);- - `kicad_document_info` reads the editor's file from its command line;- - a lock file left by a killed KiCad is cleared again, as on Windows. Before, the bridge could not read this machine's name, so the lock stayed, and every closed-file edit answered `board_open`;- - Adom Bridge's host-app detection also finds snap and Flatpak KiCad.-- **Limits on Linux:**- - a single key reaches KiCad only when its window already has focus (`not_focused` otherwise);- - coordinate clicks are not available;- - a modal dialog that opens because the bridge pressed a menu item or button holds the accessibility bus until it closes. It is read from its screenshot, which is why `kicad_teardrops` is not Linux-ready yet.-- **Verb battery:** runs against Linux boxes too, with OS-aware paths, and scratch copies pushed with send_files instead of shell commands.- ## 1.0.98 (2026-10-31 17:28 UTC) -NATIVE (Rust) build. 1.0.98: the standard's document_info.--- **kicad_document_info:** the active KiCad document, meaning the topmost KiCad window. It reports:- - name and kind: board, schematic, library, project or model;- - modified: the leading * KiCad puts in the title;- - saved, path, project, and the sub-sheet when a schematic shows one.- - version is null, because KiCad keeps no versions.- - Read only: it opens nothing.-- **Where the path comes from:**- 1. the editor's own command line, read through Adom Bridge's process list;- 2. the project folder, for an editor opened inside the project manager;- 3. KiCad's recent-file list, as a last resort.-- pathFrom says which. windows[] lists every KiCad window, topmost first.-- Checked live on KiCad 10.0.8: with the rp2040 project's project manager, PCB editor and schematic open, it named the schematic (topmost) with its path from the command line and the project beside it.--Tested on KiCad 10.0.8 (battery 356/0/1 pass/fail/skip on DESKTOP-BAR6S0F).--## 1.0.97 (2026-10-31 17:02 UTC)--NATIVE (Rust) build. 1.0.97: tested on KiCad 10.0.8, and every build now records the KiCad its battery ran on.--- **KiCad 10.0.8:** the test laptop was updated from 10.0.7 through the bridge's own kicad_tool_update apply (the official installer, per user, no UAC). This build's full battery ran on 10.0.8.-- **Release records the tested KiCad:** build_native_release.py --battery <report> refuses a failed, partial or foreground-grabbing run, and refuses a run that tested another bridge version. Otherwise it writes the result:- - into the manifest as testedWith {kicad, battery, target, at}, which carries to public on promotion;- - into the release notes.-- **kicad_readiness:** testedKicad is the KiCad this build's battery passed on (null in a dev build). newerThanTested is true when the installed KiCad is newer.-- The battery report records the KiCad and bridge it tested (report.json "tested", and the report's heading).--Tested on KiCad 10.0.8 (battery 355/0/1 pass/fail/skip on DESKTOP-BAR6S0F).--## 1.0.96 (2026-10-30 23:02 UTC)--NATIVE (Rust) build. 1.0.96: three more standard verbs answered fully: net_connections, remove_items and doc_params (standard 0.30.4).--- **kicad_net_connections:** every pad with its net and owner (U1.3) in mils. Per net it gives the span and the airline length: the minimum spanning tree over its pads, the shortest copper a router could need.-- **kicad_remove_items:** deletes free tracks, arcs and vias given by their geometry, exactly as kicad_routing_state reported them (the standard's y-up frame; frame:"kicad" for KiCad's own).- - One result per item: ok, or err:not found.- - An open board takes it as one Undo step; a closed board is written with a backup.- - expectedRevision is optional, and the live edit still refuses a board that changed while it was prepared.-- **kicad_doc_params:** parameters KiCad's title block has no field for (DrawnBy, DocumentNumber, ...) are now kept as the project's text variables, which the title block and any text show as ${Name}. The reply lists them all as params, with the standard's set and saved. Before, those parameters were reported as notStored.--## 1.0.95 (2026-10-30 21:41 UTC)--NATIVE (Rust) build. 1.0.95: the standard's cross_check from its engine, eleven verbs that were partial under other names, now under their own, and KiCad found on any drive (engine 0.24.0, standard 0.30.0).--- **kicad_cross_check:** compares the schematic's designators with the board's, computed by the standard's engine.- - The reply gives matched, onSheetNotOnBoard and onBoardNotOnSheet.- - The sheets are read through KiCad's own netlist of the whole hierarchy. The board is read from the open PCB editor when it serves the board, otherwise from the file.- - sameProject (with a _warning) says when the sheet and the board sit in different folders.- - Checked: on a copy of the rp2040 project with C1 deleted from the board, cross_check names C1 as on the sheet but not the board, and the matched count drops by one.-- **kicad_list_library:** the symbols in a .kicad_sym or the footprints in a .pretty folder, the kind taken from the path (data.names, data.count).-- **kicad_open_document, kicad_open_project, kicad_activate_document:** open or bring up a board, schematic or project by path in the background. A board opened this way is the one the PCB editor's API serves.-- **kicad_render_board_layers:** board layers rendered to PNG or SVG through kicad-cli without a window.- - Fourteen presets (copper, silkscreen, mask and paste of either side, outline, drill at real size, assembly, fabrication), or a layer list.- - Bottom views are mirrored so they read from below, and dpi is honoured.-- **kicad_export_bom:** the project's BOM CSV from its root schematic, with files and outputDir.-- **Fixed: KiCad installed on a drive other than C: is found (wiki #137).** The bridge searches in this order:- 1. KiCad's registry key;- 2. KiCad's uninstall entries;- 3. Program Files\KiCad, Program Files (x86)\KiCad and \KiCad on every fixed drive;- 4. kicad-cli on PATH.- - bridge.json's detect globs include D: to G:, so Adom Bridge's host-app check sees such an install too.- - Library paths were already built from the detected root.- - The skill now says never to install KiCad in the Linux workspace: the bridge runs the user's own kicad-cli.-- **kicad_routing_drc:** KiCad's DRC on the board as the PCB editor holds it (unsaved edits included), with the project's rules and zones refilled. The reply is clean, errors, unconnected and violations[{rule, severity, description, at}].-- **kicad_set_track_width:** sets the width of the existing tracks and arcs on nets or a net class, optionally on one layer (top, bottom, mid N). An open board takes it as one Undo step through kicad_modify_track; a closed board is written with a backup. The reply gives byNet and drcAfter.-- **kicad_footprint_primitives:** one placed part's lines, arcs, pads (size, hole, net, solder and paste mask expansion in µm) and texts, in mils on the board, with other kinds counted.-- **kicad_rules_by_kind:** every rule the board answers to, grouped by kind and sorted by KiCad's precedence: custom .kicad_dru rules, then net classes, then Board Setup minimums.-- Engine adom-eda-checks 0.24.0 vendored unchanged (standard 0.30.0). kicad_bom stays KiCad's own: it was already exact.--## 1.0.94 (2026-10-30 20:46 UTC)--NATIVE (Rust) build. 1.0.94: the standard's export_3d, import_design, edit_pours, tool_update and import_step, and its schematic part verbs in their own arguments (standard 0.29.5).--- **kicad_export_3d:** writes the board's 3D model through kicad-cli in stl, glb, vrml, ply, brep, xao, u3d, stpz or 3dpdf.- - components:false exports the bare board. copper, silkscreen and soldermask add those layers.- - obj, 3mf, iges and other formats KiCad can't write are refused by name (not_supported), with what to use instead.-- **kicad_import_design:** brings an EAGLE, Altium, PADS, CADSTAR, Fabmaster, P-CAD or SolidWorks PCB board into a new KiCad project through kicad-cli's importer. The reply counts the parts and nets and gives the importer's warnings.- - kicad-cli imports the board only, and the reply says so (schematicImported: false). The schematic comes in through KiCad's File > Import > Non-KiCad Project.- - Formats KiCad has no importer for, such as OrCAD, are refused by name.-- **kicad_edit_pours:** changes existing pours in place: clearance, minimum width, thermal reliefs (on/off, spoke, gap) or the pad connection, priority, net, and whether islands are kept. Each pour comes back with its settings before and after.- - A closed board is written (a .adom-bak is kept) and refilled by kicad-cli, and pourState is the copper after the refill.- - On a board open in the PCB editor, each zone KiCad holds is updated in place through its API (UpdateItems: id, outline and every other setting kept) as one Undo step. The board is then refilled, and the settings are read back from KiCad (verified).- - expectedRevision is optional, and KiCad's revision or pour_state's are both accepted.-- **kicad_pour_state:** each pour now carries its id (the zone's uuid), which kicad_edit_pours takes.-- **kicad_sch_place_part, kicad_sch_connect, kicad_sch_edit, kicad_sch_swap_part:** the standard's schematic part verbs, under their own names and with their own arguments. They work alone and as kicad_sch_batch ops, and KiCad's own arguments still work too.- - Arguments: positions in mil from the grid point at the sheet's lower-left corner (y up, landing on KiCad's 50 mil grid), parts by designator, and a library as a .kicad_sym path or a table nickname plus the symbol's name.- - sch_connect takes connections [{designator, pin, net, kind: label|power|wire|noerc|port}]. sch_edit takes edits [{designator, action: move|moveBy|rotate|designator|comment|delete}].- - Each list is one write, all or nothing. The reply is in the standard's terms: connected/applied, failed, results, atMil, endMil.- - sch_swap_part wires each pin the new symbol puts somewhere else back to where the old pin ended (pinsJoined).-- **kicad_sch_connect_to_net:** kind "wire" draws the stub alone, with no label.-- **kicad_sch_replace_symbol:** joinMoved:true rejoins moved pins.-- **kicad_tool_update:** the standard's tool_update.- - check compares the installed KiCad with the latest stable (current, offered).- - apply installs the offered version with the official installer, per user with no UAC. It refuses while KiCad is open and lists the open documents, unsaved ones marked.- - auto reads or sets KiCad's own update check.-- **kicad_import_step:** answers not_supported with the reason: KiCad places 3D models only as a footprint's own model. It suggests kicad_add_body or kicad_export_step instead.--## 1.0.93 (2026-10-30 19:47 UTC)--NATIVE (Rust) build. 1.0.93: kicad_schematic_review checks the drawing too (engine 0.23.0, standard 0.28.0).--- **kicad_schematic_review:** sends the engine each sheet's drawing (sheetDrawing): wires, buses, junctions, no-connects, labels, pins and symbol boxes. The engine's drawing checks (dangling wires, floating labels, overlapping symbols, ...) now run on KiCad schematics, and the reply says drawingChecked.- - Power symbols that name a net count as power labels. Sheet pins count as hierarchical labels.- - netlist.names lists every label and port name joined into one net, so the engine can flag two names on one net.- - Checked on three real designs, where the engine agrees with KiCad's own kicad_sch_check:- - pic_programmer: one real overlap (R10 drawn over U4) and one info.- - StickHub: one info (no bulk capacitor on a rail).- - The rp2040 sample: the same 10 overlapping-symbol pairs.-- **Fixed: PWR_FLAG no longer joins nets.** A PWR_FLAG was read as a power symbol naming the net "PWR_FLAG", so every net carrying one became one net. This raised a false shorted_nets on pic_programmer and StickHub. Only power symbols whose pin is a power input name a net now.-- **Fixed: escaped label names.** KiCad writes a / in a label as {slash} (and : as {colon}, and so on) in some files. kicad_sch_check and kicad_schematic_review compared the raw text, so the labels VPP/MCLR and VPP{slash}MCLR split one net in two (a false "reaches only R18.2" on pic_programmer). Names are now read as KiCad compares them.-- Engine adom-eda-checks 0.23.0 vendored unchanged (standard 0.28.0).--## 1.0.92 (2026-10-30 19:04 UTC)--NATIVE (Rust) build. 1.0.92: the standard's sch_batch and log_tail shapes (#29).--- **kicad_sch_batch:** ops can be named by the standard's schematic verbs (sch_place_part, sch_connect, sch_edit, sch_swap_part, sch_text, sch_junction, ...) as well as KiCad's own (place_symbol, wire, ...), mixed in one batch.- - sch_connect and sch_edit run as the KiCad verb their arguments call for (wire, label, connect_to_net, no_connect; move, rotate, delete, set_property).- - sch_place_part, sch_connect, sch_edit and sch_swap_part take KiCad's arguments.- - The reply names ercBefore and ercAfter. Any op that fails, an unknown one included, is named by failedOp, and nothing is written.- - Checked: sch_place_part, sch_text, place_symbol and sch_edit ran in one write on a copy of the inverting amplifier sheet.-- **kicad_log_tail:** answers the standard's entries [{level, text}] and next beside lines and nextOffset.--## 1.0.91 (2026-10-30 18:31 UTC)--NATIVE (Rust) build. 1.0.91: three of the standard's library verbs that 1.0.90 refused are now real (#29).--- **kicad_cleanup_libs (the standard's cleanup_libs):** a symbol or footprint library's parts that no given project uses, and parts that repeat another part under a different name (same pins and graphics, or same pads).- - A plan by default. apply with remove takes out only the names listed, which must be in the plan unless force.- - A symbol library keeps a .bak; a footprint file moves to <Lib>.pretty.removed, so both can be put back.- - A symbol another symbol extends counts as used.-- **kicad_build_symbol_modes (the standard's build_symbol_modes):** gives a symbol its De Morgan alternate body, KiCad's second display mode.- - The alternate drawing (rectangle, polyline, circle, arc) goes into every unit, with the standard body's pins copied and any pin shapes changed (inverted inputs). The symbol is marked as having two body styles.- - Checked: KiCad plots the alternate as mode 1 (kicad_symbol_mode).-- **kicad_add_symbol_art (the standard's add_symbol_art):** draws artwork inside a symbol's body rectangle, scaled to fit with a margin and centred, keeping its aspect.- - The art is given as polylines, or taken from another symbol's polylines (adom-symbol's baked chip outline).- - Checked: KiCad plots the art inside the body, with the pins untouched.--## 1.0.90 (2026-10-30 18:00 UTC)--NATIVE (Rust) build. 1.0.90: four more of the standard's verbs (#29: every standard verb answered on every bridge).--- **kicad_swap_footprint (new; the standard's swap_footprint):** puts another library footprint on one placed part.- - The part keeps its position, rotation, side, reference, fields, schematic link, DNP/BOM flags and lock.- - Pads keep their nets by number, and assigns ("9=GND") sets nets on any pad. The reply lists the pads whose net was kept and any pad left without a net.- - Checked: C1 on a copy of KiCad's pic_programmer demo went from an axial to a radial capacitor with both nets kept, and KiCad's DRC is clean.-- **kicad_plate_vias (new; the standard's plate_vias):** in a footprint library, every drilled pad becomes a plated hole through every copper layer when it isn't one already: a thru_hole pad on only some copper layers, or an smd pad with a drill (thermal vias written as surface pads).- - Numbered unplated holes are listed, and plated only with includeNpth. Running it again changes nothing.-- **kicad_normalize_bodies (new; the standard's normalize_bodies):** moves a footprint's 3D model onto its pad cluster (by its offset) when the body sits more than minShiftMm from the pads' centre. Models already on their pads are left alone.- - Checked: a SOIC-8 whose STEP sat 50 mm away was moved back onto its pads.-- **kicad_symbol_mode (new; the standard's symbol_mode):** one body style of a library symbol as a picture, plotted by KiCad: mode 0 the standard body, mode 1 the De Morgan alternate, per unit.- - Checked: 74LS00 in mode 1 shows the OR body with inverted inputs. A symbol with no De Morgan body refuses mode 1.-- The standard's Altium-only verbs (licence, sign-in, agent, extension, symbol-mode specs, cleanup) are now answered in KiCad's equivalents: mapped where KiCad has the job, otherwise unsupported with the reason. panel maps to kicad_send_key's View > Panels toggle; activate_document maps to kicad_open_board and kicad_open_schematic.-- **The standard's Altium-only verbs now exist in KiCad and refuse with not_supported**, each with the reason and what to call instead. They are add_symbol_art, cleanup_libs, build_symbol_modes, license, signin, connect_workspace, agent_start/stop/status/recover, selftest_script_error, extension_ping, install_extension and install_receipt.- - An AI that calls kicad_license learns that KiCad is GPL with no licence page (instead: kicad_license_status) rather than meeting unknown_verb.--## 1.0.89 (2026-10-30 17:18 UTC)--NATIVE (Rust) build. 1.0.89: camera choreography (the standard's animate_view).--- **kicad_animate_view (new; the standard's animate_view):** camera steps run inside the bridge, so the timing is tight enough to narrate over. Everything runs in the background.- - On the PCB editor: zoom_all, zoom_in and zoom_out, as KiCad's own actions through its API.- - In the 3D Viewer: views, orbits, rolls, pans, flip and rotate90, as its View-menu commands.- - view_3d and view_2d switch between them within one call.- - Presets: punch_in, orbit, showcase3d, flip and finale.- - Steps KiCad has no command for (isometric, ortho) are listed in skipped, never faked. The schematic lane is refused as not_supported, because KiCad 10's Schematic Editor has no API.- - Checked on the pic_programmer demo board:- - Four zoom-ins took the editor from zoom 2.84 to 20.00, centred.- - 17 3D commands ran in 6.8 s, with isometric skipped.- - The orbit preset's 36 commands ran in 5.4 s.-- **kicad_lint_library: the standard's library lint** (engine 0.21.1, standard #28). The bridge's structural check and KiCad's own verdict now come with the shared content rules, each finding carrying its rule and severity.- - Footprints: no pads, zero-size pads, unnumbered and duplicate pad numbers, no courtyard, origin off the part, no silkscreen, no 3D body, no description.- - Symbols: no pins, unnumbered and duplicate pins, no footprint, footprint not found, pins without pads and pads without pins, no part number.- - kicad-cli's refusals pass through as tool_refuses_file.- - Linting a symbol library alone now finds its linked footprints ("Lib:Name": a Lib.pretty beside it, else KiCad's footprint library tables), so pins are checked against pads. Those footprints' own findings stay out, since they aren't the library being linted.- - KiCad's paste-only apertures (pads with no copper) and unplated holes need no number and no pin.- - A kicad-cli that doesn't answer in time is now reported as "verdict unknown", no longer as KiCad refusing the file.- - Checked: KiCad's Package_SO library (401 footprints) has no errors, and its split exposed pads are quiet: engine 0.21.1 counts touching pads with the same number as one land.- - Checked: a 6-pin symbol linked to a SOT-23-5 gives "pin 6 has no pad on Test:SOT-23-5".-- **kicad_update_footprints_from_library: the standard's reply** (#27, now a core verb).- - Each row carries footprint and library; the count is also given as differs.- - padsChanged lists what changed (type, shape, position, rotation, size, drill, layers).-- **Engine:** adom-eda-checks 0.21.1 (EDA Bridge Standard 0.24.2).--## 1.0.88 (2026-10-30 16:11 UTC)--NATIVE (Rust) build. 1.0.88: KiCad 10's design variants (the standard's variants).--- **kicad_variants (new):** which parts are fitted on which build, on the project's own files.- - Lists every variant with its not-fitted parts, its changed fields (alternates) and its description.- - Makes or changes a variant: marks parts not fitted, or puts them back. Describes or removes one.- - It writes what KiCad 10 writes: the variant list in the .kicad_pro, and each part's variation inside its instance on whichever sheet holds it. A .bak is kept beside every file written.- - KiCad reads the result back: kicad-cli's netlist for the variant must mark exactly those parts DNP, or the call fails with verify_failed.- - Unlike Fusion's read-only list, KiCad's verb edits variants. Field changes (another Value in a variant) are listed but made in KiCad's own variant editor.- - Checked on a copy of KiCad's pic_programmer demo (two sheets):- - Lite with P1, C1 and R1 not fitted: KiCad marks the same three DNP.- - R1 put back: C1 and P1 are left.- - A second variant with D1: matches.- - Lite removed: only Pro is left in the project.- - kicad-cli's BOM for Lite leaves out C1 and P1.-- **kicad_sch_zoom (new; the standard's sch_zoom):** finds parts on a schematic sheet and frames them. It's the schematic half of cross-probing; kicad_zoom_to is the board half.- - Answers where each part sits (mm) and which ones the sheet doesn't place.- - The proof frame is KiCad's own plot of the sheet, cropped to the parts with a margin and each part outlined.- - KiCad 10's Schematic Editor has no API, so the frame is a plot of the saved sheet and the open editor's view isn't moved.- - Checked: C1 on the pic_programmer root sheet, and U1, P2, C6 and C7 together on its pic_sockets sub-sheet, each outlined in the plot.--## 1.0.87 (2026-10-29 22:00 UTC)--NATIVE (Rust) build. 1.0.87: refusals the caller can fix no longer say they may be a bridge bug.--- **Every verb:** the "THIS MAY BE A KICAD BRIDGE BUG ... file an issue" line no longer appears on a refusal the caller fixes.- - That covers a missing or bad argument, a name or file that isn't there, unsaved changes, a stale revision, a DRC refusal, no simulation model, a missing reason, and not supported.- - Unexpected failures still carry it.- - Before, kicad_current_capacity without its nets told the AI to file a bug for its own missing argument.-- **kicad_simulate: noise analysis** (standard #23). `analysis {type: "noise", source, output, reference?, sweep, points, start, stop}` runs ngspice's .noise.- - It answers the onoise and inoise spectra (V/rtHz) as signals, and totals {onoise, inoise} in V.- - Checked: an RC filter's output noise is 4.0714 nV/rtHz, the resistor's sqrt(4kTR). The total is 0.2035 uV against sqrt(kT/C) = 0.2036 uV.-- **kicad_simulate: Monte Carlo** (standard #23). `monteCarlo {runs, distribution: uniform|gaussian, seed, tolerances: {resistor, capacitor, inductor, dcSource} %}`.- - Every part of a kind with a tolerance is drawn again for each run. Uniform draws stay within the tolerance; gaussian draws use sigma = tolerance/3, clipped to the tolerance.- - Each run's step lists the values drawn, and the same seed draws the same values.- - Transistor and digital tolerances are refused with not_supported.- - Checked: on an inverting amplifier, every run's gain matches 20*log10(R2/R1) from its own drawn values, less the op-amp model's finite-gain error.-- **kicad_height_check and kicad_placement_check: turned parts** (engine 0.20.0, standard #26). A part turned off the 90° grid now sends its body's real rectangle, so it no longer collides through its bigger bounding box.- - Checked: StickHub, with a 45° LQFP and its decoupling caps, went from 70 false collisions to 1.- - That 1 is a crystal tucked into the LQFP's lead-free corner, which a body box (leads included) can't tell apart. KiCad's courtyard DRC passes.-- **Engine:** adom-eda-checks 0.20.0 (EDA Bridge Standard 0.23.0).--## 1.0.86 (2026-10-29 21:25 UTC)--NATIVE (Rust) build. 1.0.86: fixes found by testing 1.0.85 on real boards.--- **kicad_review_board:** KiCad's per-pin "unconnected-(U5-PB0-Pad16)" nets now count as no net, the way kicad_schematic_review treats them.- - KiCad gives every pin the schematic leaves unconnected a net of its own, so each one used to come back as a one-pad net warning.- - Checked: the ESC board went from 23 such warnings to a clean review. The rp2040 sample's real findings are unchanged.-- **kicad_review_board: the vendor part number and datasheet checks now read the symbols' fields.** The parts sent from KiCad's netlist used to carry none, so every part was flagged even when it had Mouser, DigiKey and LCSC numbers.- - Empty fields, and KiCad's "~" for no datasheet, don't count.- - Checked: on a copy of the rp2040 sample, giving C1 its Mouser, DigiKey and LCSC numbers and a datasheet clears both of its findings.-- **kicad_footprint_from_land_pattern** (engine 0.19.1, standard #25, found testing 1.0.85):- - SOT-23-5's pins 4 and 5 now line up with pins 1 and 3 (±0.95 mm). Before, they sat 0.95 mm apart, centred, so the part did not fit.- - The courtyard holds the pads and body only, so a 7 mm BGA's is symmetric at ±3.75 mm instead of stretching to the pin-1 dot.-- **Engine:** adom-eda-checks 0.19.1 (EDA Bridge Standard 0.22.2).-- **Verb tests:** the test run never overlaps another bridge's test run on the same machine. It waits up to 30 minutes, then refuses (#112).--## 1.0.85 (2026-10-29 20:44 UTC)--NATIVE (Rust) build. 1.0.85: simulation sweeps and the parts a run left out, and footprints from a datasheet's land pattern laid out by the standard's engine.--- **kicad_footprint_from_land_pattern:** the standard's engine lays the footprint out, and KiCad's own writer turns it into the .kicad_mod and the 3D body box.- - The layout is the same as before: KiCad's code and the engine's both came from the Altium bridge, line for line. One copy now, so the engine's fixes reach KiCad.- - It covers pad numbering for chip, dual, SOT, quad and BGA packages, the exposed pad, the courtyard, silkscreen clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer.- - Replies carry engine. A missing number is still named ("dual needs padLength (mm)").- - Checked: a 3x3 mm QFN-16 with exposed pad was built live and read back by KiCad with 17 pads and its 3D model.-- **Engine:** adom-eda-checks 0.19.0 (EDA Bridge Standard 0.22.1).- - A width rule with only a minimum (Fusion's EAGLE rules) now counts as the board's track width.- - The engine's new panelize and teardrops plans are not used here: KiCad keeps its own kicad_teardrops (KiCad's own teardrop generator) and kicad_panelize.-- **kicad_simulate and kicad_export_spice: notSimulated** (standard #23, as Altium 0.2.82 reports it). These are the sheet's parts that KiCad left out of the netlist because they have no simulation model.- - A partly modelled sheet still runs, and the reply says which parts the result does not cover. KiCad writes a part with no model as a stub with no nodes (J1 __J1), which made ngspice refuse the whole circuit; the run now leaves those stubs out.- - A sheet where no part has a model refuses with no_model instead of an ngspice error.- - Parts the designer excluded from simulation are left out on purpose and are not listed.- - Checked: an RC with a connector on its output runs and names J1; a sheet of two connectors refuses no_model.-- **kicad_simulate: sweeps** (standard #23, as accepted from Altium). Arguments: `sweep {parameter, start, stop, step, scale?}` and `temperatures {start, stop, step}`.- - ngspice has no .step, so the circuit runs once per value: a part's value ("R4") is set with alter, a model parameter ("Q1.BF") with altermod on the part's model, and the temperature with .temp.- - Each run's signals carry step ("R1 = 2k", "T = 85"); the reply says how many runs there were (at most 50).- - A part that isn't in the netlist, or a model the netlist doesn't define, is refused with bad_args, never one run passed off as the sweep.- - Checked live:- - R1 at 1k and 2k puts the RC filter's corner at 1588 Hz and 794 Hz.- - A diode's forward voltage is 753, 696 and 639 mV at -10, 25 and 60 °C.- - Its emission coefficient N at 1 and 2 gives 0.69 V and 1.38 V.--## 1.0.84 (2026-10-29 19:49 UTC)--NATIVE (Rust) build. 1.0.84: a whole-board design review, and the standard's engine 0.17.2.--- **kicad_review_board (new; the standard's review_board):** one read of the board, and of the schematic beside it through KiCad's own netlist, reviewed by the standard's engine.- - Finds duplicate designators, parts off the board or near its edge, placeholder values on the board and the sheets, one-pin nets, parts without a footprint, missing vendor part numbers and datasheets, and disabled or missing design rules.- - Board Setup's clearance, track width and minimum through hole go in as the board's rules. KiCad's DRC always checks shorts, clearance, track width and hole size, so no rule class reads as missing on a KiCad board.- - skipped and notImplemented name what it could not judge (a placement grid needs gridMm; decoupling is kicad_schematic_review's job).- - Checked: the rp2040 sample's 12 PIN** test points come back as a duplicate designator, its 20 parts without vendor part numbers as warnings.-- **kicad_schematic_review:** each part now carries its value and comment too.-- **Engine:** adom-eda-checks 0.17.2 (EDA Bridge Standard 0.20.2).--## 1.0.83 (2026-10-29 19:26 UTC)--NATIVE (Rust) build. 1.0.83: a schematic review before the board is updated, and the standard's engine 0.16.0.--- **kicad_schematic_review (new; the standard's schematic_review, which KiCad answered only in part before):** reviews every pin against KiCad's own netlist of the design, with each pin's electrical type.- - Errors: power pins on no net, outputs driving each other.- - Warnings: pins on no net, one-pin nets, inputs nothing drives, and IC supplies with no decoupling capacitor to ground.- - KiCad's single-pin unconnected-(...) nets count as no net, and a pin with a no-connect flag is expected to be unconnected.- - Checked: a wired RC filter is clean; the unwired rp2040 sample shows its 5 unconnected power pins and 2 supplies without a capacitor.-- **Engine:** adom-eda-checks 0.16.0 (EDA Bridge Standard 0.19.0). KiCad keeps its own route_pair, length_tune, auto_classes, silk_designators, pin_pad_check and find_space: they work on KiCad's real courtyards and its live editor.--## 1.0.82 (2026-10-29 18:43 UTC)--NATIVE (Rust) build. 1.0.82: five more of the standard's app-tier verbs, and KiCad's own Python from the bridge.--- **kicad_eval:** a Python snippet run with KiCad's bundled Python and its pcbnew module, headless.- - With filePath the board is loaded as `board`, and the snippet's `result` comes back as JSON.- - Checked: 149 footprints and 83 nets read in 0.4 s; a failing snippet returns its traceback.-- **kicad_run_script:** a .py of your own run the same way, with arguments; stdout, stderr and the exit code come back.-- **kicad_foreground:** brings a KiCad window to the person. It needs a reason written for them, captions it first, and refuses without one.-- **kicad_dialog_log:** every dialog the bridge's sweeps dismissed since it started, newest last.-- **kicad_license_status:** KiCad's licence in plain words: GPL-3.0-or-later, no seat, sign-in or trial.--## 1.0.81 (2026-10-29 17:52 UTC)--NATIVE (Rust) build. 1.0.81: the standard's engine 0.12.2 and two assembly-check fixes.--- **kicad_assembly_check** (standard #24):- - pads sharing one centre (one land drawn as several pads) no longer read as a 0 mm pitch;- - a footprint with no pads (a logo) no longer counts as a part, so a bottom-side logo no longer fails "parts on one side only".-- **kicad_plan_route** (engine 0.12.2): routes are smoothed into straight and 45-degree segments with the same clearance (smooth:false turns that off). A pour holding the route's own end pad is exempt from avoidPours (avoidExempt), and planner reports withoutAvoid.-- **Engine:** adom-eda-checks 0.12.2 (EDA Bridge Standard 0.15.2).--## 1.0.80 (2026-10-29 17:35 UTC)--NATIVE (Rust) build. 1.0.80: framing the view on a part or a net that works with KiCad in the background (wiki #121), and the standard's engine 0.11.0.--- **kicad_zoom_to (new; the standard's zoom_to):** frames the open PCB editor on parts ({refs}) or a whole net ({net}), or fits the board.- - It runs KiCad's own Zoom to Selected Objects through its API, so the view really moves while KiCad stays in the background. A menu command posted to a background window was acknowledged but did not move it.- - The selection it makes is cleared afterwards (keepSelection keeps it), and the API answers normally right after.- - Checked: U5 framed from zoom 4.5 to 33 in the background; a net and the whole board too.-- **kicad_select_net / kicad_select_items:** their hints now point to kicad_zoom_to.-- **Engine:** adom-eda-checks 0.11.0 (EDA Bridge Standard 0.14.0). KiCad keeps its own IPC-2581 exporter.--## 1.0.79 (2026-10-29 17:03 UTC)--NATIVE (Rust) build. 1.0.79: simulation in the EDA Bridge Standard's shape (standard 0.14.0, #23).--- **kicad_simulate** takes and answers the standard's simulate:- - analysis as {type: op | dc | ac | tran | raw, ...};- - probes by net ({net: "OUT"}) and source current ({current: "V1"});- - signals[{signal, x, y, xUnit, yUnit, phase}] with each probe's own points. points is a ceiling that always keeps the first, last and extreme points, and AC phase is unwrapped;- - engine names the ngspice and KiCad versions ("ngspice 46 (KiCad 10.0.7)").- - SPICE text, V(/OUT) probes and the earlier fields still work.-- **kicad_export_spice** says its dialect (ngspice).-- Both are now standard verbs (simulate, export_spice) rather than KiCad's own.--## 1.0.78 (2026-10-29 16:41 UTC)--NATIVE (Rust) build. 1.0.78: SPICE simulation from a KiCad schematic, headless, through the ngspice KiCad installs.--- **kicad_simulate (new, wiki #89):** runs a sheet through ngspice, the simulator KiCad itself uses.- - Analyses: transient, AC, DC sweep or operating point, given in the call or carried by the schematic.- - Probes are node voltages V(/NET) and source currents I(V1). Each comes back downsampled with its first, last, min and max (AC as dB and phase), and as a CSV file.- - ngspice runs in a child process, so a circuit that crashes it never takes the bridge down, and a failed analysis returns ngspice's own message.- - Checked: an RC low-pass of 1k and 100n is -2.99 dB and -44.9 degrees at 1.58 kHz, and passes 0.85 of a 1 kHz sine.-- **kicad_export_spice (new):** KiCad's own SPICE netlist of a sheet, with its elements, its analyses and the model files it includes.-- **Engine:** adom-eda-checks 0.10.4 (standard 0.13.4): plan_route keeps a pour holding the route's own end pad out of avoidPours, echoes its pour settings, and smooths grid staircases into straight and 45-degree segments.--## 1.0.77 (2026-10-28 21:57 UTC)--NATIVE (Rust) build. 1.0.77: fanout plans that are safe on a routed board, decoupling caps that leave room for the fanout, and the standard's engine 0.10.3.--- **kicad_plan_fanout:** now planned by the standard's engine, safe on a routed board (standard #22):- - pins whose net already reaches the pad are skipped (includeRouted plans them anyway);- - conflicts are measured against other nets' pads, tracks, arcs and vias;- - an exposed pad's via grid steps round holes already there;- - crossesPours names the pours the plan runs into.- - Each call comes back ready for kicad_route_net. The old argument names still work.-- **kicad_place_decoupling:** leaves room for the IC's fanout (leaveFanout, default true): no cap pad lands on the escape stubs or vias of the IC's other pins. Checked on the ESC board's U5.-- **Fix:** 1.0.76's fanout conflict check missed every stub starting at a pad and measured a stub against a pad as an outline. It is now correct, with regression tests.-- **kicad_plan_route** (engine 0.10.3): avoidPours takes a list of nets, and pourCost makes crossing pours a soft cost.--## 1.0.76 (2026-10-28 21:31 UTC)--NATIVE (Rust) build. 1.0.76: fanout conflicts, routes that know the pours, the tour template back, and the standard's engine 0.10.0.--- **kicad_plan_fanout:** conflicts[] lists every planned via and stub closer than the clearance to another net's pad anywhere on the board, measured by the pad's real outline. Vias count on every layer, stubs on their own layer (standard #22).-- **kicad_plan_route** (engine 0.10.0, standard #21):- - crossesPours names other nets' pours the route runs through, and avoidPours:true keeps it out of them;- - through vias are checked on every copper layer;- - the board's strictest clearance rule decides.-- **kicad_place_decoupling:** a cap the plan can't move keeps its spot, so no other cap is planned onto it.-- **Tour template (wiki #136):** tour-pack/template is rebuilt: one AdomTour R_AdomTour on a sheet, a small board and a project. The default kicad-tour pack has its files again.- - KiCad 10.0.7 opens all three; the only ERC/DRC findings are the unconnected resistor and the AdomTour library, which the tour installs.- - The 7 schematic unit tests that read it pass again.--## 1.0.75 (2026-10-28 21:05 UTC)--NATIVE (Rust) build. 1.0.75: decoupling caps sorted by the IC they belong to, routes planned at the board's net class, and the standard's engine 0.7.0.--- **kicad_place_decoupling:** by default it now leaves out two kinds of cap, listed in leftOut[] (standard #22):- - signal_net: a net not named like a supply with only one cap to ground, such as a crystal's load cap;- - nearer_another_ic: a cap nearer another IC's pin on the same net.- - On the ESC board, U1 no longer takes every +VBAT cap: they split between U1 and U2. Naming caps still places them as asked.- - A through-hole pad of a part on the other side now blocks the spot too.-- **kicad_plan_route:** the project's Default net class goes to the engine as the board's width and via rules, and planner.widthFrom / viaFrom say which rule set them.-- **kicad_open_board:** when KiCad's API serves another PCB editor's board, it now says so (api_serves_another_board), not "a different board with the same file name".-- **Engine:** adom-eda-checks 0.7.0 (EDA Bridge Standard 0.10.0).--## 1.0.74 (2026-10-28 20:40 UTC)--NATIVE (Rust) build. 1.0.74: 3D bodies made from package dimensions and routes planned between pads, both from the standard's engine 0.6.0.--- **kicad_generate_body (new):** a part's 3D body from its package family and the datasheet's JEDEC letters, to IPC-7351 proportions, with an optional marking on top.- - Families: chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, can, crystal and header.- - The engine writes the STEP. With libraryPath it goes in <Lib>.3dshapes, and footprintName attaches it.- - Checked: a SOIC-8 marked LM358 sits on its footprint's pads at 1.75 mm.-- **kicad_plan_route (new):** a path for one net between two pads, around the board's copper and inside its clearance rules, on the layers allowed, with vias where they help. Nothing changes.- - routeNet is kicad_route_net's own arguments, ready to run.- - Checked on the ESC board: /DRV_GHC (R12.1 to U4.13) is a 2.5 mm F.Cu path, and kicad_route_net took it unchanged with no new DRC error.-- **Engine:** adom-eda-checks 0.6.0 (EDA Bridge Standard 0.9.0). These were KiCad's last two standard verbs outside the app tier.--## 1.0.73 (2026-10-28 20:13 UTC)--NATIVE (Rust) build. 1.0.73: parts framed in KiCad's own render, a stricter painted-canvas check, and the standard's engine 0.4.1.--- **kicad_render_board ref / refs (wiki #105):** frames those parts in KiCad's own renderer.- - The camera pivots on the parts' pad centre and zooms to fit them with marginMm around; data.framed reports the pivot and zoom.- - rotate "-45,0,45" gives an isometric view.- - Checked on the ESC board: J1 is centred, top, bottom and iso; J1 with Q1 frames both.-- **canvasRendered (wiki #100):** a canvas now also needs 10% of its sampled rows to vary.- - A blank white 3D viewer with one black strip (John's capture) had passed; it is now reported as not drawn.- - Real views measured 33 to 100%.-- **kicad_footprint_models:** the battery now changes a model's offset live, reads it back and restores it (wiki #101).-- **Engine:** adom-eda-checks 0.4.1 (standard 0.7.1). placement_check lists a part under a raised one as `stacked`, not as an overlap.--## 1.0.72 (2026-10-28 19:28 UTC)--NATIVE (Rust) build. 1.0.72: four more checks from the standard's engine 0.4.0 (EDA Bridge Standard 0.7.0), and a placement review.--- **Engine:** adom-eda-checks 0.4.0, vendored unchanged. Every engine reply now carries the engine's own `engine` field instead of a version written by hand.-- **kicad_thermal_vias and kicad_via_fence:** planned by the engine, as all three bridges now do (standard #18).- - The via comes from the project's Default net class, and the fence's clearance from the board's largest clearance rule.- - KiCad's DRC still runs before any via is placed, live or in the closed file.-- **kicad_height_check:** computed by the engine from kicad_bodies; `complete` says whether every part had a body (standard #19).-- **kicad_placement_check (new):** a placement review before routing, from the engine with the parts' 3D bodies.- - It finds parts whose pads and bodies overlap on one side, parts off the board or over its edge, pads near the edge, and parts on a mounting hole.--## 1.0.71 (2026-10-28 19:00 UTC)--NATIVE (Rust) build. 1.0.71: pictures of library parts, and layer visibility in the open board.--- **kicad_library_views:** each part in a library drawn for review, as image files, headless (kicad-cli; nothing opens).- - The symbol, one PNG per unit; the footprint with its pad numbers; the 3D body seated on its pads, an isometric view zoomed out for a tall body.- - The device view: an SVG table of every symbol pin against a footprint pad, with any pin without a pad and any pad without a pin listed.- - From a .kicad_sym the footprint is the symbol's Footprint field; from a .pretty folder, symbolLibraryPath gives the symbol.- - A footprint with no model, or a model file that does not resolve, is reported ok:false for 3d, never drawn empty.-- **kicad_set_layer_visibility:** shows or hides one layer of the open board (by name or layer id) and reads it back.--## 1.0.70 (2026-10-28 17:55 UTC)--NATIVE (Rust) build. 1.0.70: schematics drawn from a netlist, a plan-only library-bundle install, and the standard's engine 0.2.1.--- **kicad_build_schematic:** a new sheet (and project) from a parts list and a netlist with library symbols.- - One column per block, a stub and a net label on every connected pin, and the title block filled.- - Then KiCad's own netlist of the sheet is compared with the nets given, pin by pin, and ERC runs.-- **kicad_install_library_bundle planOnly:true (wiki #106):** installs nothing.- - It answers the steps with time estimates, and per part the evidence the install will give: symbol always, footprint when the bundle has it, 3d when its model is in it.- - The real install now reports steps[] with the same ids.-- **Engine:** adom-eda-checks 0.2.1 (EDA Bridge Standard 0.5.1), vendored unchanged; nothing in KiCad's checks changes.- - The checks' engine field had kept saying 0.1.2 since 1.0.67; a test now ties it to VENDOR.md.--## 1.0.69 (2026-10-28 17:28 UTC)--NATIVE (Rust) build. 1.0.69: length tuning, mouse-bite panels, silkscreen prep, component classes, new boards from footprints, and schematic sheets read from the file.--- **kicad_panelize separation:"tabs":** the copies stand gapMm apart (2 mm default) inside a frame with rails all round.- - Each board's edges are broken at its tabs on Edge.Cuts (the rout path), and every tab carries five 0.6 mm mouse-bite holes on the board side.- - The tab layout and JLCPCB's panel rules (for tabs and for V-score) are the Altium bridge's, and the reply now carries them as jlcpcb.rules, with boardsAt and nearestCopperToEdge.-- **kicad_build_board:** a new board from a parts list and a netlist: the outline (a rectangle with rounded corners, or a polygon), every footprint from its library by its own origin, pad nets set, read back pin by pin.-- **kicad_test_board:** one footprint or a ';' list from a library on a new board, or added to an existing one.-- **kicad_read_sheet_file:** a .kicad_sch's parts read from the file (designator, symbol, value, uuid, unit, position, fields and pins), one entry per placed unit.-- **kicad_length_tune:** lengthens a net to targetMm, or to matchTo's length (pair skew, a byte lane). It draws a serpentine of square bumps in its longest straight track with room on one side, sized to add exactly the missing length, clear of other copper and the edge. Live as one Undo step, or in the closed file, after KiCad's DRC shows no new error.-- **kicad_silkscreen_prep:** on a closed board, in one call:- - turns on KiCad's own clipping: the Gerbers subtract the solder mask from the silkscreen;- - deletes silkscreen lying outside the board;- - moves designators that sit on pads;- - reports KiCad's silk DRC counts before and after.-- **kicad_component_classes:** lists, makes or removes KiCad 9+ component classes. An optional room is a rule area plus a custom DRC rule that flags any member outside it. Both were checked against KiCad 10.0.7's DRC.--## 1.0.68 (2026-10-28 16:15 UTC)--NATIVE (Rust) build. 1.0.68: ordering and footprints, five more of the EDA Bridge Standard's verbs.--- **kicad_bom:** the schematic's parts merged with the board, one line per MPN, checked for what an order lacks.- - Writes bom.csv, JLCPCB's BOM and CPL, the Mouser and DigiKey BOM-tool files and bom.json.- - The grouping and the field names matched across libraries are the Altium bridge's, so the files read the same in both tools.-- **kicad_assembly_check:** the board against JLCPCB's Economic and Standard assembly tiers, and the cheapest one it passes. Parts the line never places (excluded from BOM or position files, DNP, board-only) are left out.-- **kicad_jlcpcb_order:** in one folder:- - the fab check;- - the Gerbers and drill, zipped;- - the BOM and CPL;- - the assembly tiers;- - the order-form values;- - every issue in one list, with `ready`.-- **kicad_footprint_from_land_pattern:** a footprint from a datasheet's land pattern (chip, SOIC/DFN, SOT, QFP/QFN, BGA, exposed pad), with silkscreen clear of copper, pin-1 marks, the courtyard and a 3D body box.-- **kicad_silk_designators:** every reference designator placed upright beside its part, clear of pads, bodies, existing silkscreen and the other labels, never below the board's minimum text height.- - A closed board gets the labels written into the file. KiCad's DRC checks the result first, and a label it flags is reported, not written.- - A board open in KiCad is moved live as one Undo step, unless its footprints carry KiCad 10's units block: then it refuses, nothing changed (see below).-- **Fixes:**- - kicad_silk_fields_batch no longer drops footprints' units blocks. KiCad 10.0.x moves a field only by sending its whole footprint back, and loses the footprint's units block (pin groups) doing so. Such a move now refuses with field_move_drops_units, nothing changed, unless allowUnitsDrop:true.- - kicad_silk_state gives a part with a round courtyard its real body box, not a flat line.- - The netlist reader keeps symbol fields and DNP flags for the BOM.--## 1.0.67 (2026-10-27 22:24 UTC)--NATIVE (Rust) build. 1.0.67: board checks, the EDA Bridge Standard's checks tier: 14 verbs that check a board and place the vias a check asks for.--- **The standard's shared engine** (adom-eda-checks, vendored unchanged): kicad_return_path (with a ready kicad_apply_zone_plan that closes each gap), kicad_copper_balance, kicad_impedance (with ready kicad_custom_rules width and pair rules), kicad_current_capacity, and kicad_fab_check: the board against a fab's record from adom/pcb-design-rules (fab:"jlcpcb"), naming the page and the date it was read. kicad_validate_for_fab stays as KiCad's own DRC check. kicad_silk_state also answers the standard's shape (components, lines, texts with x, y and size).-- **Computed by the bridge:** kicad_net_lengths (pair skew, matched-length groups), kicad_check_orientation, kicad_find_space, kicad_height_check (3D heights, regions, collisions), kicad_pin_pad_check (symbol pins against footprint pads), kicad_auto_classes (net classes from names, written with apply).-- **Vias:** kicad_thermal_vias fills an exposed pad and kicad_via_fence lines fast tracks and/or the board edge with ground vias. Both run live as one Undo step, or into the closed file, and only after KiCad's DRC (pours refilled) shows they add no error.-- **kicad_board_report:** every check, plus DRC, ERC, the board against the schematic and the fab's limits, with a rendered picture, in one HTML file.-- **Fix:** kicad_update_pcb_from_schematic no longer reports nets with a slash in their name as changed: the board writes /VPP{slash}MCLR and the netlist /VPP/MCLR. On KiCad's pic_programmer demo it reported 11 false changes, and apply would have renamed those nets.--## 1.0.66 (2026-10-27 21:14 UTC)--NATIVE (Rust) build. 1.0.66: teardrops and V-score panels, two more of the EDA Bridge Standard's native verbs.--- **teardrops:** adds or removes teardrops with KiCad's own Edit > Edit Teardrops dialog (KiCad's API has no teardrop call), on PTH pads, SMD pads and vias, optionally track to track or the selection only. It counts the teardrops before and after and returns the board's new revision. The board's default teardrop shapes are used.-- **panelize:** steps a rectangular board into a V-score panel as a new board file:- - rows x cols copies, each with its nets renamed B<n>-<net>;- - one outline with rails, V-cut lines on Cmts.User, and three tooling holes and three fiducials;- - KiCad's DRC on the panel, compared with the board's own: newErrors counts only what the panel adds.-- A shaped board is refused, because it needs mouse bites.--## 1.0.65 (2026-10-27 20:14 UTC)--NATIVE (Rust) build. 1.0.65: any library footprint can now be added to a board open in KiCad, plus four of the EDA Bridge Standard's native verbs.--**Live adds without a copy on the board.** add_footprint and add_mounting_hole build the part from its library text as KiCad's protobuf items (commit:"create-items", one Undo step): pads, graphics, texts, fields and 3D models.-- When the same library footprint is already on that side of the board, KiCad's own encoding of it is still copied.-- Checked on KiCad 10.0.7: stock parts with through-hole and SMD pads, at 0, 45, 90 and 270 degrees and on the back, came out the same as the closed-file add. DRC reports no library mismatch for them.-- A footprint with custom-shape pads, mask or paste margins, or zones inside still refuses with live_create_unsupported and says why.--**New standard verbs:**-- **compile:** ERC and the netlist, with messages counted by class;-- **list_installed_libraries:** every symbol and footprint library KiCad loads, with missing ones flagged;-- **replicate_layout:** one channel's placement onto matching channels through move_footprint, one Undo step;-- **diff_pairs:** the pairs KiCad's router recognises by name, and the net class that sets their width and gap.--## 1.0.64 (2026-10-27 19:27 UTC)--NATIVE (Rust) build. 1.0.64: adds to a board open in KiCad, two fixes for footprints KiCad rebuilds over its API, and 17 of the EDA Bridge Standard's core verbs.--**Adding items to an open board.** KiCad 10.0.x creates nothing from text over its API, so the bridge now encodes the new items as KiCad's protobuf items itself (commit:"create-items", one Undo step):-- board_outline and import_svg are created live;-- add_footprint (with duplicateOf and count) and add_mounting_hole create a part live when the same library footprint is already on that side of the board with the same pads and graphics: the bridge copies KiCad's own encoding of it, with fresh ids, the new pose, reference, value and pad nets;-- anything else, a footprint the board lacks and update_footprints_from_library included, still refuses with live_create_unsupported, now with liveCreateWhy.--**Footprints KiCad rebuilds over its API (#126, #129).** KiCad stores every footprint it rebuilds without a polygon's repeated closing point, which DRC then reports as lib_footprint_mismatch:-- moves, live field edits and live part edits now name those parts in closingVerticesDroppedByKiCad, with how to restore them;-- a live field move (silk_fields_batch) no longer fails otherBoardDataVerified because of it.--**New standard verbs:**-- **footprint libraries:** copy_footprint, rename_footprint, remove_footprint, xcopy_footprint, add_body;-- **symbol libraries:** clone_symbol, rename_symbol, set_params, set_visibility, symbol_prims;-- **sheets:** place_on_sch, doc_params, sheet_params (the BOM write-back onto placed parts), part_parameters;-- **the board:** layers, silk_state, ripup_nets.--## 1.0.63 (2026-10-27 18:18 UTC)--NATIVE (Rust) build. 1.0.63: the EDA Bridge Standard's shapes for the 47 verbs KiCad brought to the standard (standard 0.3.0). KiCad's own names and arguments keep working.--- **Standard names:** render_board_3d, remove_parts, edit_parts, align_parts, remove_graphics, read_footprint, export_library_part, create_part_project. kicad_edit_footprint with library arguments edits the library footprint (fp_edit), as the standard means it.-- **Standard arguments:**- - sheet and project paths;- - id/ids for KiCad's uuids;- - mm-named numbers;- - bomFields on IPC-2581;- - at and box on import_svg and copy_routing.-- **Lists are one write:** sch_connect_pins pairs, sch_bus_entry entries, sch_edit_label edits, sch_text texts and sch_junction add+remove run as one kicad_sch_batch. kicad_edit_parts takes `edits: [{ref, ...}]`, a different edit per part, in one undo step, live or on the file.-- **y up:** frame:"standard" on all 47. Sheet verbs answer y up too; sch_pins also answers one flat pins list.-- **estimate_cost** names its price source (the bridge's built-in JLCPCB-style rates; no check date is recorded) and lists prices per quantity.-- **create_part_project** refuses to overwrite an existing project unless overwrite:true.-- **kicad_describe** marks these verbs with standardShape.-- **The release script** now pushes only reference/kicad-<ver>.json to the standard, which standard-lane owns.-- **Battery:** phase 9 runs the standard shapes on a real KiCad.--## 1.0.62 (2026-10-27 16:30 UTC)--NATIVE (Rust) build. 1.0.62: John's 1.0.61 verbs (Konnect parity) brought into the EDA Bridge Standard and the verb battery. Sixteen standard names now answer as aliases of the verbs that do the same job (net_classes, repour, save, update_pcb, annotate, create_symbol, create_footprint, read_symbol, remove_symbol, sch_sheet_symbol, selection, route_pair, fab_check, find_symbol, export_manufacturing, add_rule); a catalog test makes every new verb a standard name, an alias or a reviewed KiCad-only job, and each release now syncs bridge.json, refreshes the standard's KiCad coverage and writes every verb into the README. Fixes: adding items to a board open in KiCad (add_footprint, add_mounting_hole, import_svg, board_outline, update_footprints_from_library) failed as a vague rollback; KiCad 10.0.x (4 and 7 checked) creates nothing from text over its API, so these now refuse with live_create_unsupported and say to close the board, which edits the file; a KiCad lock file left by a KiCad that is no longer running no longer blocks file edits. The verb battery covers all 227 verbs on KiCad 10.0.7, the 78 new ones in project, schematic, library, fab and board scenarios.--## 1.0.61 (2026-10-27 14:58 UTC)--NATIVE (Rust) build. Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schematic: components, pins, items, rotate, region move, annotate, replace and update symbols, field reset, wire delete/split, labels, junctions, net stubs, pin-to-pin wiring, text, buses, atomic batches, sheets and hierarchy, sheet pins, connectivity trace, checks, connectivity fixes, PNG render with visual baselines, design review. Board: outline, layer count, graphics, mounting holes, SVG import, refill, save, add/flip/delete/edit/align footprints, update footprints from library, footprint graphics and 3D models, selection and cross-probe, update PCB from schematic. Routing and rules: modify track, diff pairs, copy routing, netclasses, board rules, custom rules, clearance check, placement score, decoupling placement, fanout plan. Fab: DXF, GenCAD, IPC-2581, ODB++, IPC-D-356, one-zip fab package, fab pre-flight, cost numbers. Projects and libraries: create, info, rename, snapshot; footprint and symbol create, edit, info; library search. PCM: file:// and private wiki repositories, zipPath installs (#132).--## 1.0.59 (2026-10-26 22:55 UTC)--NATIVE (Rust) build. 1.0.59: the EDA Bridge Standard board model. kicad_routing_state {model:"standard"} answers the standard's board model (mm, y up, custom pads as rectangles with copperPoints, the outline with arcs, unconnected from the copper with isolatedCopper, an order-independent revision), live or from the file; the edit verbs take its revision and, with frame:"standard", its coordinates. New: kicad_layer_stack, kicad_pour_state, kicad_bodies (3D heights from STEP/VRML), kicad_netlist (expect comparison, pin types), kicad_board_summary, kicad_list_nets, kicad_list_designators, kicad_copper_layers, kicad_layers_in_use, kicad_component_data, kicad_component_pins, kicad_sch_no_connect (#131). Fixes: kicad_remove_route honours dryRun (#134); placement outline box follows arcs and circles, ring courtyards no longer collide (#133); PCM libraries resolve ${KICAD10_3RD_PARTY} (#130); the no-ipc build compiles again. Unconnected matches KiCad's DRC on six KiCad 10 demo boards; verb battery on DESKTOP-BAR6S0F (KiCad 10.0.4).--## 1.0.1 (2026-09-12 13:54 UTC)--NATIVE (Rust) build. 1.0.1: Freerouting on the user's terms. kicad_freerouting {status|install|uninstall} installs Freerouting's own self-contained bundle (88 MB download, 147 MB on disk, its Java runtime inside, nothing installed on the PC, no UAC) only when the user asks, as a background job with progress in status, and removes it on its own. kicad_autoroute {engine: freerouting} writes the Specctra DSN from the board, routes headless with a deadline, and lands the SES copper as native undo steps through the IPC API (or into the closed file with a backup), then DRC. Every autoroute reply names both engines; the AI engine stays the recommendation. Verified on ConfRoomROG: install 6 s, six-net fixture in 3 s, DRC clean, live apply six undo steps, uninstall clean. kicad_uninstall removes the engine too. New kicad-autorouting skill.--## 1.0.0 (2026-09-12 12:36 UTC)--NATIVE (Rust) build. Native bridge 1.0.0: one Windows executable, no Python or PowerShell, nothing for ab to provision. 96 verbs: everything the Python bridge did plus schematic edit in place (six verbs, ERC-validated), kicad_autoroute (ai engine live), kicad_rescan_libraries verified by loading, kicad_progress and verb_times, a multithreaded server so status and progress polls answer during long verbs, the etiquette loop inside the binary, and the six-beat tour. Live routing on the Rust IPC client with the same revision guard and native undo steps. Gate on ConfRoomROG from this exact zip: 71 pass, 4 fail (three foreground notes from another session's Chrome windows, one cleanup-while-open), 15 skip; kicad_demo passing. In-KiCad Python plugin retired. Known: ab's native recorder drops 4K takes past a minute (adom-bridge#189).--## 0.9.351 (2026-09-11 11:36 UTC)--Rust port plan phase 0: seeds KiCad's six do_not_show_again nags, api.enable_server, update-check and eeschema prompt suppression at first-run config seeding (bridge-created files only, never a user's own settings); deletes dead launch_editor.ps1, unused handlers/foreground_guard.py and the legacy tour/ orchestrator. Plan and comparison sub-readmes published under docs/. Gate: ConfRoomROG 71 pass / 1 fail (known idle-box foreground grant on kicad_launch) / 15 skip; adom_library_heal verified live.--## 0.9.350 (2026-09-09 13:49 UTC)--kicad_model_check and model diagnostics on the 3D show verbs (#88)--## 0.9.350 (2026-09-09): 3D model diagnostics (#88)--- New read-only `kicad_model_check {library, footprintName}` or `{boardPath}`: per (model ...) reference it- reports where KiCad would look (project dir, then the 3D search paths; never next to the .kicad_mod),- whether the file exists, whether it is a STEP/VRML with geometry, hidden flags and implausible scale,- with coded warnings and hints. Footprints that need no body (test points, fiducials, mounting holes,- board-only parts) are exempt. `renderVerified` is always false: the viewer frame stays the render proof.-- `kicad_show_3d_chip` and `kicad_show_3d_board {filePath}` attach a `models` summary and a `_modelHint`- when a body cannot be there, so a pads-only 3D view arrives with its reason.--## 0.9.349 (2026-09-09 13:17 UTC)--kicad_status carries operation state for timeout recovery (#87)--## 0.9.349 (2026-09-09): kicad_status answers "did my operation finish?" (#87)--- `kicad_status` is the manifest's statusVerb, the verb ab tells a caller to poll after its 130 s- budget runs out. It carried plugin inventory only. It now also carries `operations`: every phase- in flight (all callers, each frame named), the last finished runs with ok/error and finishedAt,- and `yours` filtered to the caller. The run log records the caller and the error text of a failed- run so that answer is exact. Plugin liveness is unchanged.--## 0.9.348 (2026-09-08 20:32 UTC)--Focus guardian identity survives hwnd reuse; editors spawn without activation (pre-promotion run findings)--## 0.9.348 (2026-09-08): editors launched by open_board/open_schematic start without activation--- The focus guardian's "push a KiCad window behind the user once" now keys on (hwnd, pid) and expires- closed windows: Windows recycles hwnd numbers, so a new editor could inherit an already-pushed number- and never be pushed at all. The push stays one per window (John's rule). Editors opened by- open_board/open_schematic are spawned with SW_SHOWNOACTIVATE like kicad.exe. Measured on an idle- ConfRoomROG (no user input for minutes): a new editor still ends on top, because Windows grants a- new process the foreground once the foreground lock timeout has expired; with a user active Windows- blocks it. Not new behaviour; recorded here so the pre-promotion runner reading is understood.--## 0.9.347 (2026-09-07 19:10 UTC)--kicad_close honours unsaved work (#84); verified footprint rescan (#51)--## 0.9.347 (2026-09-07): kicad_close honours unsaved work (#84), footprint rescan verified (#51)--- `kicad_close` no longer force-kills an editor that is asking "Save changes?": it reports- errorCode unsaved_changes with the dialog text and leaves the window open. `discardChanges:true`- presses Discard for the caller and keeps the close graceful; `force:true` is still the only kill.- The graceful wait per window is 8 s (was 5 s).-- `kicad_rescan_libraries` takes footprints:["Lib:Name", ...] and loads each in the Footprint Editor after- the tree refresh, so `verified` is measured (the last one stays shown). Measured on 0.9.343: a footprint installed two hours after- pcbnew started loads after the in-place refresh, no restart. The stale-editor wording on- `kicad_install_footprint` now says that instead of demanding a process restart.--## 0.9.343 (2026-09-07 18:29 UTC)--Footprint loads with two pcbnew processes; list_footprints reads the fp-lib-table (#81)--## 0.9.343 (2026-09-07): footprint loads with two pcbnew processes (#81)--- The Library Tree toggle behind every background footprint load asked "the" pcbnew plugin and, with- two pcbnew processes alive, got a stray PCB Editor that has no Footprint Editor frame; the tree- stayed hidden and the load typed into nothing. It now asks the pcbnew that owns the Footprint Editor.-- `kicad_list_footprints` lists every library in the fp-lib-table with URIs expanded like KiCad does- (stock Resistor_SMD and friends included), not only Documents/KiCad/<ver>/footprints.--## 0.9.342 (2026-09-07 18:13 UTC)--First-run wizard deadlock: time-boxed dialog clicks and bounded sweeps (#83)--## 0.9.342 (2026-09-07): first-run wizard deadlock (#83)--- The dialog sweep clicked dialog buttons with a synchronous SendMessage. The first-run "KiCad Setup"- wizard opens its "Are you sure?" Confirmation modally inside the Cancel handler, so the click never- returned and the only code that would answer the Confirmation was the code waiting on the click.- On a truly fresh KiCad (config deleted) that held kicad_open_schematic past ab's 130 s budget.- Clicks are now time-boxed (SendMessageTimeout, 1.5 s) and both sweep loops stop after 25 s.--## 0.9.341 (2026-09-07 18:06 UTC)--Release-day fixes: stock library path resolution (#80), screenshot_all sizeKB (#81), readiness re-detect (#82), routing DRC completeness (PR 82), verified source pushes (#79), full verb-test coverage (#78)--## 0.9.341 (2026-09-07): release-day fixes from the multi-host verification--- #80: `${KICAD10_SYMBOL_DIR}` and the other stock library variables now resolve to the installed- KiCad's share directory (per-user installs included) with Configure Paths overrides honoured;- `kicad_list_symbols {"libraryName":"Device"}` works on every box.-- #81: `kicad_screenshot_all` no longer dies with an internal sizeKB error when a capture reply- carries no size.-- #82: `kicad_readiness` and `kicad_list_versions` re-detect when a cached kicad.exe has vanished- (KiCad uninstalled while the bridge ran) instead of reporting ready until a bridge restart.-- Live routing (PR 82, Codex): DRC snapshots refill zones; capped violation lists are flagged and the- error-delta check refuses to certify a truncated report (`drc_incomplete`, no copper changed).-- #79: the release tool verifies every source push and refuses to release on a failed chunk.-- #78: the verb test runner ships its rp2040 fixture, learns the Windows user from the box, covers every- live verb (tests or a stated skip), pings pcbnew instead of eeschema (no scripting there on KiCad 9/10),- and only counts a foreground steal when a user window was actually displaced.--## 0.9.340 (2026-09-07 16:56 UTC)--Live routing verbs through KiCad's IPC API (routing_state, route_net, remove_route, routing_validate), offline copper verbs (board_pads, add_track, add_via, route), kicad_ipc_api, KiCad 10 name-based nets, vendored routing deps--## 0.9.340 (2026-09-07): live routing through KiCad's IPC API--- New live copper verbs from the per-net routing branch: `kicad_routing_state`, `kicad_route_net`,- `kicad_remove_route`, `kicad_routing_validate`. Official KiCad IPC editing (KiCad 10.0.1+ with the- IPC API server on), native Undo per call, revision and net guards, snapshot DRC before commit.-- Offline file verbs for closed boards: `kicad_board_pads`, `kicad_add_track`, `kicad_add_via`, `kicad_route`.-- `kicad_ipc_api`: read or flip KiCad's IPC API server switch (needs a KiCad restart).-- The board parser reads KiCad 10 name-based nets as well as numbered nets.-- The release zip now vendors the IPC client (`routing_deps/`: kicad-python 0.8.0, protobuf, pynng,- cffi; Windows wheels for CPython 3.11 to 3.13). Nothing is pip-installed on the desktop.-- Not an autorouter and not an electrical sign-off: the caller chooses every waypoint.-- Tour work from 0.9.339 (two-part beats, 98% layout, 3D bodies) is unchanged.--## 0.9.339 (2026-09-05 19:16 UTC)--Tour motion: zoom depth per beat (one step for a single part, two plus one for a sheet or board)--## 0.9.338 (2026-09-05 18:55 UTC)--Tour: reopen the board when the file on disk is newer than the running pcbnew, so attached 3D bodies actually show--## 0.9.337 (2026-09-05 18:50 UTC)--Tour: a 3D model file that is not installed on this box counts as dead too; the MOSFETs then get KiCad's TDSON-8-1 body and the board reloads--## 0.9.336 (2026-09-05 18:46 UTC)--Tour: dead 3D model references are removed as balanced blocks (the regex missed the MOSFETs), so KiCad's TDSON-8-1 body attaches and the board reloads--## 0.9.335 (2026-09-05 18:43 UTC)--Tour: 3D body attach report lists what it saw for the MOSFET footprints (debug)--## 0.9.334 (2026-09-05 18:39 UTC)--Tour: two-part beats (A loads with its own line, B explains and moves once verified), part timestamps on the record, 3D body attach report says where it looked--## 0.9.333 (2026-09-05 18:36 UTC)--Tour: every beat in two parts (A loads the window with its own line, B starts once verified with the explaining line and the motion); part timestamps on the record; dead 3D bodies replaced by KiCad standard ones--## 0.9.332 (2026-09-05 18:29 UTC)--Footprint open: a hidden Library Tree filter box counts as missing (wx hides the pane, the control survives), so the pane is shown and waited for; tour prep summary (3D bodies attached, cache restart) on the progress record--## 0.9.331 (2026-09-05 18:22 UTC)--Footprint open: the plugin-free background loader also shows a hidden Library Tree pane before typing (arav-rog: the pane fix only covered the fallback path); tour reloads the board after attaching 3D bodies; prep summary on the record--## 0.9.330 (2026-09-05 18:11 UTC)--Tour: attach KiCad's standard 3D bodies to footprints the board left without one (six MOSFETs get TDSON-8-1); Footprint Editor Library Tree pane is shown once and waited for, never toggled back off--## 0.9.329 (2026-09-05 17:59 UTC)--Tour: restart KiCad during prepare when the footprint file is newer than the running KiCad (process start time vs file mtime), not only when it is new; startedAt and narrationAt on each step record--## 0.9.328 (2026-09-05 17:52 UTC)--Tour: footprint install leaves an identical file untouched (mtime bump made KiCad's cache look stale on every tour after the first); per-beat startedAt and narrationAt on the record for syncing a recording--## 0.9.327 (2026-09-05 17:32 UTC)--Tour: editor and viewer windows at 98 percent of the work area, centered (John)--## 0.9.326 (2026-09-05 17:21 UTC)--Tour: all editor and viewer windows at 95 percent of the work area, centered; 2D motion aims at the featured part (position parsed from the board and schematic) and checks the canvas after each gesture; 3D motion re-frames when the board is clipped or tiny--## 0.9.325 (2026-09-05 13:35 UTC)--Footprint open: when the Footprint Editor's Library Tree pane is hidden, show it through the frame's own View menu (plugin wm_command) before navigating; arav-rog had it hidden and every load failed--## 0.9.324 (2026-09-05 13:21 UTC)--Footprint open: never restart KiCad for a stale cache when the editor never opened (plugin_not_running restarted KiCad twice on arav-rog); tour footprint beat ensures pcbnew is open and its plugin alive; window rows carry minimized/image so 'in front' skips minimized windows--## 0.9.323 (2026-09-05 13:08 UTC)--Tour: a brand-new footprint restarts KiCad during prepare (before any window), never mid-beat; the footprint beat waits retryAfterMs on kicad_restarted_retry--## 0.9.322 (2026-09-05 13:07 UTC)--Tour: a brand-new footprint restarts KiCad during prepare (before any window), not mid-beat; install_footprint reports existedBefore; the footprint beat waits retryAfterMs on kicad_restarted_retry--## 0.9.321 (2026-09-05 13:03 UTC)--Tour: 'in front' ignores shell popups, minimized windows and our own overlays (arav-rog measured RustDesk minimized and PopupHost as the top window); footprint beat retries twice without the plugin wait; 2D motion recenters when a pan or zoom empties the canvas--## 0.9.320 (2026-09-05 12:50 UTC)--Tour: leave KiCad's main window as KiCad opens it; footprint beat waits for the project manager plugin and retries (cold KiCad on arav-rog failed both footprint beats)--## 0.9.319 (2026-09-05 12:47 UTC)--Tour: read the unwrapped desktop_list_windows payload (verify and foreground worked off a local list with no z-order on arav-rog); beat captions bottom-left clear of the demo panel; small top captions announce each foreground and the acks--## 0.9.318 (2026-09-04 23:13 UTC)--Wiki #48: the 20 s idle healer measured window reachability against the primary monitor only and pulled windows on a second monitor back to the primary; it now measures against the monitor the window overlaps--## 0.9.317 (2026-09-04 17:05 UTC)--Tour remote: dashboard presses recorded, acked with a caption, and honoured at the next gesture like panel presses--## 0.9.316 (2026-09-04 17:02 UTC)--Tour remote: FIFO press queue (no press lost), poll-drained presses recorded, presses on progress--## 0.9.315 (2026-09-04 16:53 UTC)--Tour remote: prev/next/pause cut the beat in flight at the next gesture; back goes to the beat before the one on screen; presses and acks recorded--## 0.9.314 (2026-09-04 16:25 UTC)--Tour layout: restore a window's bounds before the tour closes it so KiCad's sticky geometry is never corrupted--## 0.9.313 (2026-09-04 16:21 UTC)--Tour layout: read the unwrapped desktop_list_monitors payload so windows are placed in their 80 percent slots--## 0.9.312 (2026-09-04 16:20 UTC)--kicad_demo layoutProbe diagnostic; layout uses the monitor work area--## 0.9.311 (2026-09-04 16:14 UTC)--Fix: demo.py lacked the json import used by the new window-bounds save/restore (0.9.310 would have silently skipped saving bounds).--## 0.9.310 (2026-09-04 16:13 UTC)--Tour layout for the recording (John): each beat's window is placed at 80% of the primary screen in its slot (symbol upper-left, footprint nudged, chip 3D upper-right, schematic lower-left nudged, board 2D lower-left, board 3D lower-right) and the KiCad manager is centered at its own size; every bound is saved first and restored at the tour's end, stop, failure, or the next start (leftovers file), so KiCad's sticky sizes end up as the user had them. Every caption's reply is recorded on progress; a press on the remote gets an immediate acknowledgement caption at the top of the screen; the settle check waits up to 8 s for two identical captures instead of sampling once.--## 0.9.309 (2026-09-04 15:59 UTC)--Tour 3D beats no longer end with Zoom to Fit: in KiCad's 3D viewer that command also resets the orientation, so the board beat ended top-down on John's laptop; the beat now ends on the isometric it built (zoom in/out net to zero, framed at the opening fit).--## 0.9.308 (2026-09-04 15:59 UTC)--Motion gate: GetForegroundWindow can lag AD's z-order right after a raise, which aborted the symbol beat's motion at its first gesture on John's laptop while AD had already measured the window on top; the gate now waits up to 1.5 s for the OS to agree. Panel record keeps the callback URL across stage-advance shows.--## 0.9.307 (2026-09-04 15:52 UTC)--Tour verification (John: 'tool your bridge code to definitively know that the correct thing is showing'): every beat now MEASURES, after its raise, that its own window is the top of AD's z-order, that the live title carries the expected document, that the GL canvas is rendered (kicad_ui canvas probe) and that two captures 0.8 s apart are identical (settled). The result rides on the step and on progress as ; motion runs only on a verified window, otherwise the step says exactly which fact failed and which window was actually on top. Zoom discipline: 2D beats start and end on extents with two wheel clicks in and back out; 3D beats zoom one menu step in and out and end on Zoom to Fit while staying isometric.--## 0.9.306 (2026-09-04 15:46 UTC)--Tour 3D motion is now deterministic: KiCad's own View menu commands (Rotate X/Z by 10 degrees, Zoom In/Out, Zoom to Fit) resolved from the live menu bar and posted as WM_COMMAND, so the isometric and the orbit are the same on every DPI and need no mouse or foreground; mouse drags remain only as the fallback (they flipped the chip viewer and did nothing in the board viewer on 2026-09-03). Each step in the result now carries the window handle and title it drove.--## 0.9.305 (2026-09-04 15:38 UTC)--Tour window targeting (John's laptop: fourteen KiCad windows from days of demos; the 2D beat showed an empty untitled PCB Editor and the 3D beat the old sample cube): every beat now targets the tour's own document titles (esc-g431 schematic and board, the Adom STM32 symbol, the LQFP footprint), reuses an existing window instead of opening a duplicate, and opens each 3D view from that exact editor (open_3d_viewer accepts pcbHwnd/fpHwnd). The clean slate at start also closes old adom-demo documents and an empty untitled PCB Editor.--## 0.9.304 (2026-09-04 15:29 UTC)--Tour identity fix (John's dashboard run, 2026-09-03): ab refused the demo panel, every caption, every raise and every window close from the tour thread with 'carries no caller identity' (toast fallback, no captions, nothing in front). Every loopback call the bridge makes now carries identity: the X-Adom-Caller headers of the request being served, or the bridge's own self caller otherwise; the tour thread and ab's callback thread adopt the identity of the request that started the tour. Also: a failed panel show no longer demotes later tours to the toast (per-tour reset, one retry), and every 3D viewer plus the Footprint Editor are closed before the beats so the footprint beat opens a fresh, re-indexed editor.--## 0.9.303 (2026-09-04 15:15 UTC)--Tour sync: the verb-side control channel (kicad_demo {control}) is cleared at tour start too, so a pause/next/exit queued against a previous tour never steers a new one; completes the stale-press drain of 0.9.302.--## 0.9.302 (2026-09-04 15:13 UTC)--Tour sync fixes (John's run on 0.9.301): stale panel presses from the previous show are drained before a tour starts (his earlier NEXT presses made the new tour jump to step 3 at once); the narration mute is reset from the call's muted flag at thread start and pushed to the panel on the first show; STOP from the remote sets stopping on progress immediately so the dashboard can close its own pill; progress carries stopping.--## 0.9.301 (2026-09-04 15:07 UTC)--Taskbar hygiene (John: frozen progress bars on every KiCad window, no Adom badge): the boot sweep that clears leftover bars ran once at +6 s while ab was still restarting and failed silently; it now retries across the first two minutes until a window answers, and a five-minute idle reaper clears progress and badge on every KiCad window whenever the bridge is driving nothing. Filed adom/adom-bridge#138 for a progress TTL on ab's side.--## 0.9.300 (2026-09-04 15:01 UTC)--Tour 3D motion recalibrated: the orbit drags totalled about a third of the viewer width and turned the board nearly edge-on (KiCad's trackball is roughly 0.1 degree per pixel). Now ~30 degrees of tilt and ~40 of yaw for the isometric, then three gentle orbits with zoom, slower and smoother for the recording.--## 0.9.299 (2026-09-04 14:54 UTC)--Tour board-in-3D fix: KiCad's 3D viewers are unowned top-level windows, so picking 'the viewer owned by the PCB Editor' never matched and the chip's viewer was never closed (0.9.298 test-box run: board3d failed with the viewer actually open). The beats now take the viewer that APPEARS after their open, the chip's viewer is remembered and closed by handle before the board's opens, with the topmost viewer as fallback.--## 0.9.298 (2026-09-04 14:51 UTC)--Fix: the 0.9.297 tour job died on a NameError (_EXIT undefined) before its first progress update, so Play the demo showed the dashboard's transport pill and nothing else (John, lost, 2026-09-03). _EXIT is defined and the job's reset sits inside its try so any early failure lands on progress as an error instead of a silent stall.--## 0.9.297 (2026-09-04 14:08 UTC)--Tour: motion on every beat for the recording (fit, wheel zoom in/out, middle-drag pans in the 2D editors; Home, an isometric left-drag and three orbits with zoom in the 3D viewers), sent only while the beat's window is confirmed in front and aborted at the next gesture on STOP or when the user switches away. The board-in-3D beat now closes the chip's 3D viewer first and picks the viewer owned by the PCB Editor (the last beat was showing the chip's viewer by title match). STOP from the remote's X now clears the caption and ends the tour within the beat, audio already off. Per-beat motion reports ride on the result.--## 0.9.296 (2026-09-04 14:02 UTC)--Tour foreground fix (John: 'why do I not see you foregrounding any kicad window'): the beat raise called SetForegroundWindow from the bridge process and reported true regardless; Windows refuses that from a background process while the user works elsewhere. The bridge now lifts its no-activate mark, asks ab's desktop_bring_to_front to raise the beat's window once, and reports foregrounded only when the z-order afterwards shows that window on top.--## 0.9.295 (2026-09-04 13:55 UTC)--The tour now walks a real Adom board, adom/esc-g431 (149 footprints, 227 symbols): its STM32G431 symbol and LQFP-48 footprint are lifted from the wiki files at tour time and installed into the Adom library, then symbol, footprint, chip in 3D, schematic, board 2D, board 3D in that order. Captions were never painting because ab requires reason on desktop_caption; fixed, medium size, one line per beat. Narration re-rendered to match. Wording no longer claims the background: each beat's window is brought to the user once. Dead local 3D model paths in the board are dropped.--## 0.9.294 (2026-09-04 10:37 UTC)--Demo panel callback URL fix: the bridge read its bound port from a second copy of the server module and sent no eventCallbackUrl to ab (panel presses reached the tour only via poll). The port now travels through the environment, so every press is POSTed to the bridge the instant it is clicked. Verified on the test box: a press to the loopback route paused within the beat, muted at once, and resumed on play.--## 0.9.293 (2026-09-04 10:34 UTC)--kicad_demo progress now reports panel (last show reply, stage, callback URL), muted, muteSource, lastPress and paused; each step in the final result carries foregrounded (whether the beat's window was brought forward once).--## 0.9.292 (2026-09-04 10:30 UTC)--Demo panel: reason is sent on every show (ab marks it required on show; a stage-advance show without it was refused live and the remote stayed at 1/7), and every show's reply is recorded on kicad_demo progress (panel: shown, stage, callbackUrl, error) so a refused show is visible instead of silent.--## 0.9.291 (2026-09-04 10:28 UTC)--kicad_demo adopts the ab 2.1.17 demo-panel contract: the first show passes eventCallbackUrl (loopback POST /demo/event on the bridge's own port) so a press lands the instant it is clicked; mute/unmute silence or replay the beat's narration at once; pause/stop silence at once and apply at the beat boundary; stage-advance shows send only the stage so a user's pause or mute is never undone; the poll stays as backstop and skips presses already delivered. The tour now foregrounds each beat's window exactly once (John: the user must see which window is being demoed, but keeps full control between beats); no persistent focus guardian during the tour. One short ab caption per beat stays.--## 0.9.290 (2026-09-04 09:32 UTC)--kicad_demo drives ab's desktop_demo_panel (Adom Bridge 2.1.0+, wiki adom/adom-bridge#116): the floaty play/pause/next/back/stop remote is shown at stage 0 with the seven stops, polled between beats, stage and state updated per beat, hidden on done/exit/error; the toast transport remains only as the fallback for an older ab. Every beat paints one short ab caption (KiCad tour n/7: ...) for about the beat's length instead of a paragraph for a minute (John, 2026-09-03).--## 0.9.289 (2026-09-03 19:26 UTC)--John, 2026-09-03: no caption on a Symbol Editor launch or rescan reopen (the 0.9.285 one was large, shown too long, and wrong on his laptop: it promised 'behind your window' while KiCad raised itself in front while loading). Launch is silent. _foreground now also reports editorInFrontAtEnd, measured from AD's z-order at return time, so a response never claims background about a window the user can see on top. Dev skill records the rule: one early look, no fighting the user for the foreground, captions only on the opt-in allowFocusSteal path and short.--## 0.9.288 (2026-09-03 19:00 UTC)--Captions before a KiCad window appears now name the calling AI thread (John saw one on his laptop and could not tell which thread was opening the editor). The Symbol Editor close used by rescan goes through ab's desktop_close_window (ab 2.1.12+, graceful, never forced) with the local WM_CLOSE as fallback for older ab; responses say editorCloseVia. Bridge owns KiCad, AD owns Windows (wiki #74 item 3).--## 0.9.287 (2026-09-03 17:58 UTC)--Wiki #71 (Ray, 0.9.286 run): (1) the posted Enter now goes FIRST to the window holding keyboard focus in KiCad's GUI thread (GetGUIThreadInfo), and wxDataView children count as tree candidates regardless of height, so a short Libraries pane no longer yields zero targets; (2) a navigation that ran out of the call budget returns navigation_unconfirmed_within_budget with the trace and _foreground instead of the 'still coming up' contract about an editor that was already driven; (3) an editor the bridge opened after the library file was last written is never judged stale again, so repeated calls stop re-opening it (staleVeto says so); (4) symbol_editor_did_not_open carries pathwaysTried; navigation_failed responses carry errorCode and resolved fields.--## 0.9.286 (2026-09-03 17:05 UTC)--Z-order guard fix: the 0.9.285 guard read AD's window list with the wrong envelope shape and always saw nothing (field test reported 'no window was launched' during a real relaunch). Both the Symbol Editor launch guard and handlers/foreground_guard now read z-order through handlers.kicad_windows, the one parser of AD's desktop_list_windows.--## 0.9.285 (2026-09-03 17:01 UTC)--Foreground etiquette on the Symbol Editor launch (wiki #71 field test): before opening or auto-rescan-relaunching the editor the bridge captions why a KiCad window is about to appear, remembers the user's front window, and the moment the new frame exists looks ONCE at the z-order; if the frame landed above a non-KiCad user window it slides the frame back behind it with SetWindowPos(NOACTIVATE) and then leaves the z-order alone for good (alt-tab and taskbar clicks stay the user's). show_library responses carry _foreground with what happened. kicad_install_symbol success now includes kicadParser (KiCad's verdict on the written file).--## 0.9.284 (2026-09-03 16:44 UTC)--kicad_set_design_rules: response separates fabRuleViolations (DRC items citing one of the fab's rules by name) from otherViolations, and carries drcSummary; the copper layer count is read per layer entry, not per line, so a one-line (layers ...) block no longer infers 1 layer.--## 0.9.283 (2026-09-03 16:38 UTC)--Wiki #73: kicad_list_design_rules and kicad_set_design_rules consume adom/pcb-design-rules at call time (fab, layers, process; layers defaults to the board's copper count; stages <stem>.kicad_dru with backup; runs DRC with it). Wiki #62: kicad_list_symbols (bridge inventory + kicadParser), and kicad_show_library resolves an MPN/file name to the real symbol name (exact, case-insensitive, unique prefix) reporting aliasedFrom/aliasHow. Navigation trace no longer implies the filter was not applied when wx answers WM_GETTEXT with ''. kicad-bridge-dev skill records the #71 lessons.--## 0.9.282 (2026-09-03 16:25 UTC)--Wiki #71 (Ray's remaining case): on a fresh Symbol Editor the filtered tree lists the symbol but nothing is selected, so the posted Enter has nothing to activate and the posted double-click depends on row geometry (DPI). Ray measured select-then-real-Enter loads it. show_library now adds keyboard-only rounds: Down x1..3 posted to the tree candidates, then Enter, title-verified, still PostMessage only (no focus, no foreground). Success responses carry the navigation trace too, so a re-run shows which round landed.--## 0.9.281 (2026-09-03 16:19 UTC)--Wiki #71: kicad_show_library failure responses now carry a per-step navigation trace (search Edit found, parent chain, filter text as read back, tree candidates with class and rect, editor title after Down, after posted Enter, after posted double-click). Ray measured on his box that the tree lists the symbol and a real Enter loads it while the bridge's posted Enter and double-click do not; the trace is how the next build finds which hwnd the Enter must reach.--## 0.9.280 (2026-09-03 16:00 UTC)--kicad_install_symbol now passes errorCode (kicad_refuses_symbol), kicadParser and _hint through to the caller; 0.9.279 set them inside add_symbol but the verb wrapper dropped them, so the wire response had errorCode null.--## 0.9.279 (2026-09-03 15:57 UTC)--Wiki #71 second cause, measured on ConfRoomROG with KiCad's own parser: one malformed symbol makes KiCad refuse a whole .kicad_sym (Symbol Editor lists the library EMPTY) while the bridge's structural reader called it clean. New handlers/kicad_parse_check.py runs kicad-cli sym export svg (read-only) as KiCad's verdict and inventory. kicad_install_symbol and kicad_install_library refuse a file KiCad refuses before writing (kicad_refuses_symbol / kicad_refuses_library); kicad_adom_library_status, kicad_lint_library and the kicad_show_library error carry kicadParser; kicad_adom_library_heal mode:prune drops the symbols KiCad refuses (dryRun names them, original kept as .pruned.bak). Lint's checkmark now says whether KiCad was consulted. Also measured: KiCad's library index is process-wide; a reopened editor frame keeps the old symbol set until kicad.exe fully restarts.--## 0.9.278 (2026-09-03 11:49 UTC)--Wiki #72: kicad_install_library handed a single-part .kicad_sym now returns singlePartFile:true plus a hint naming kicad_install_symbol {filePath}, on both the fresh and already-registered success paths; a real multi-symbol library gets no hint. The verb still does what it did (registers the file as its own library); it just stops being silent about the shared Adom library being untouched.--## 0.9.277 (2026-09-03 02:41 UTC)--kicad_adom_library_status: orphanedLibrary now means the orphan holds symbols the registered file lacks (unique-name compare; new orphanMissingFromRegistered and orphanMissingSample). A count compare kept flagging John's orphan after the 0.9.276 merge because its 122 lenient count included 28 duplicate copies. build_release.py refuses a non-semver first argument (a stray flag was once stamped in as the version).--## 0.9.276 (2026-09-03 02:35 UTC)--heal merge reads a corrupt orphan tolerantly: the orphaned Adom library on a redirected box had 94 symbols inside the library plus 28 duplicate copies appended AFTER the closing paren (the old appender bug's fingerprint), so the strict gate refused the whole file. The heal SOURCE is now read block by block with a quote-aware depth scan, each block balance-checked on its own, duplicates deduped first-copy-wins and reported; the file WRITTEN still goes through the strict gate. Measured on the real file: 94 unique recoverable, 0 unparseable, 28 dupes.--## 0.9.275 (2026-09-03 02:32 UTC)--kicad_adom_library_heal (wiki #71 follow-up, John's call: merge): reconcile a split Adom library by merging every symbol from the orphaned bridge-default file into the registered file KiCad reads, one quote-aware add behind the balance gate with a .bak; dryRun previews; repoint mode kept as the other answer. Never runs on its own. Idempotent, refuses an unbalanced source, never edits or deletes the orphan.--## 0.9.274 (2026-09-03 02:06 UTC)--Wiki #71 root cause: on a OneDrive-redirected Documents the bridge used %USERPROFILE%/Documents while KiCad resolves the shell Documents folder, so the bridge wrote and reported symbols in a file KiCad never reads. Fix: kicad_detect resolves user_dir like KiCad (handlers/win_paths), the Adom symbol and footprint libraries are ROW-FIRST (install writes to the file sym-/fp-lib-table registers), adom_library_status reports registeredPath/bridgeDefaultPath/split/orphanedLibrary, and the library_cache_stale verdict is built from that measurement instead of a cache story (both prior explanations were disproved by Ray).--## 0.9.273 (2026-09-03 01:15 UTC)--Adopt the insiders tier: bridge.json declares insidersManifestUrl, build_release ships to insiders by default and refuses to publish without a changelog, tools/promote.py moves the public manifest only on a human's attestation. Wiki #72 groundwork.--## 0.9.272 (2026-09-02, backfilled)--Wiki #71 (Ray): stop asserting causes the bridge never checked. show_library's-library_cache_stale message claimed the editor indexed before the symbol was-added, which Ray's ordering disproved; it now describes KiCad's PROCESS-WIDE-library cache. The starting contract no longer reports "still coming up" when no-Symbol Editor window exists at all (it reported progress for 12 minutes on a-window that was never created). kicad_rescan_libraries' tree-refresh path no-longer claims parts "are now visible" - an effect it never verified - and returns-verified:false.--## 0.9.271 (2026-09-02, backfilled)--handlers/kicad_windows.py: ONE KiCad window finder replacing six per-module-EnumWindows loops, AD's desktop_list_windows first (carries z-order) with local-enumeration as fallback, 0.25s TTL because a bridge calling AD is re-entrant.-kicad_errors migrated onto it, so dialogs are matched by OWNING EXE instead of by-title. find_project_manager de-duplicated from the two editor modules.--## 0.9.270 (2026-09-02, backfilled)--kicad_screenshot_all captures via AD desktop_screenshot_window (owned popups,-coordMap, auto-pull) with the local PrintWindow kept as fallback; _canvas_probe-stays local because blank-GL-canvas detection needs raw BGRA. kicad_errors reads-TaskDialog text through AD UIA at DISCOVERY time - the previous two-step hand-off-raced and lost the message.--## 0.9.269 (2026-09-02, backfilled)--Documented the three error channels (dialogs, captured stderr, screenshots) and-kept the screenshot loop as the deliberate floor under the other two.--## 0.9.268 (2026-09-02, backfilled)--kicad_errors handles Windows TaskDialogs, whose DirectUI text win32 cannot read.--## 0.9.267 (2026-09-02, backfilled)--kicad_errors: enumerate KiCad error/warning dialogs read-only. KiCad writes no-log file and in GUI mode wxLogError becomes a message box, so the dialogs are-where a failure reason actually lives.--## 0.9.266 (2026-09-02, backfilled)--Capture KiCad's stderr instead of discarding it (launch used stdout/stderr=DEVNULL)-plus KICAD_ENABLE_WXTRACE, and add kicad_log_tail with a byte offset.--## 0.9.265 (2026-09-02, backfilled)--handlers/foreground_guard.py: caption before any action known to foreground, and-a z-order guard with ONE look inside 200ms, then hands off permanently so a user-alt-tabbing to KiCad is never fought.--## 0.9.264 (2026-09-01, backfilled)--PCM verbs resolve KICAD<n>_3RD_PARTY the way KiCad does, via the Windows shell-Documents folder - with OneDrive Known Folder Redirection that is-%USERPROFILE%/OneDrive/Documents, and installing to the wrong one made PCM report-a package installed while no library ever appeared.--## 0.9.263 (2026-09-01, backfilled)--kicad_pcm_install / pcm_list / pcm_uninstall: install a package from a KiCad PCM-repository on disk exactly as the Plugin and Content Manager does, so KiCad's own-dialog lists the result as Installed and registers the library tables itself.--## 0.9.262 (2026-09-01, backfilled)--Netlist and connectivity family: kicad_extract_netlist, trace_net,-find_connections, analyze_connections. Read-only via headless kicad-cli, so they-work with KiCad closed. Closes the largest gap against third-party KiCad MCP-servers, seven of eight of which had netlist tooling this bridge lacked.--## 0.9.261 (2026-09-01, backfilled)--Three-way merge recovering fixes that 0.9.251-0.9.260 had silently reverted (the-quote-aware .kicad_sym parser, demo captions, the #54 sub-node verify) while-keeping Colby's 0.9.251-0.9.260 work intact. Root cause fixed too:-build_release.py pushed only BRIDGE_VERSION/bridge.json/manifest, never the-source it shipped, so the release zip and the page repo disagreed and the next-person to publish from the repo reverted everything.+NATIVE (Rust) build. 1.0.98: the standard's document_info.
SKILL.md+3−522@@ -1,539 +1,20 @@ --- name: adom-desktop-kicad public: true-description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge, PCM, Plugin and Content Manager, PCM repository, kicad_pcm_install, kicad_pcm_add_repository, PCM update, KiCad on Linux, KiCad on Wayland."+description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge, PCM, Plugin and Content Manager, PCM repository, kicad_pcm_install, kicad_pcm_add_repository, PCM update." --- # adom-bridge: KiCad bridge -All commands dispatch to the KiCad Python bridge running alongside the desktop app. Invoke from this container via Bash:--```bash-adom-bridge kicad_<action> '<json_args>'-```--The leading `kicad_` routes the call to the KiCad plugin; the `<action>` names below are the plugin-side command names.--## FIRST-TIME / COLD-START: read before you panic--A fresh machine can be missing KiCad, or the reverse-bridge plugin auto-installs on your first call. These states are **EXPECTED**: don't declare defeat. Branch on the stable `errorCode` (not the prose), and relay the returned `_hint` to the user verbatim.--| Signal (`errorCode` / field) | What it means | What YOU do |-|---|---|---|-| `host_app_not_installed` (generic; `hostApp:"KiCad"`. Legacy alias `kicad_not_installed` in `errorCodeLegacy`) | KiCad (the user's host app) isn't on this machine. Bridge detects host apps but never auto-installs them. | **Don't send the user to download it.** Offer to install it, then run `kicad_upgrade '{}'`: the agent installs the official build from scratch. Then retry. |-| `kicad_version_not_installed` | The `kicadVersion` you asked for isn't installed. | Run `kicad_list_versions '{}'`; retry with an installed version or omit `kicadVersion` to use the newest. |-| `pluginAutoInstall` present in a response | Your first `kicad_*` call auto-installed the reverse-bridge plugin into KiCad (idempotent). | Nothing, expected. Subsequent calls are near-zero cost. |-| bridge not responding on the first call | Bridge is lazy-spawning the bridge (+ ~9s KiCad detection). | Wait a beat and retry; it binds once and stays up. |--**After a timeout (from 0.9.349):** ab's `_timeoutHint` says to poll `kicad_status`. Its-`operations` block answers the real question: `operations.yours.recent` lists your finished verbs-with `ok`, `error` and `finishedAt`; `operations.yours.active` lists the ones still running with-`stepLabel` and `percent` (the same view as `kicad_progress`). Not there yet means still running or-never started: keep polling, never resend a mutation blindly. `plugins[]` on the same reply is-plugin liveness, not progress.--**KiCad lives on the user's PC, never in this workspace.** Don't `apt install kicad` (or pip/conda it) in the Linux workspace to run DRC, ERC or an export: the bridge runs the user's own KiCad, `kicad-cli` included, through `kicad_run_drc`, `kicad_run_erc`, `kicad_export_*` and the other headless verbs. A KiCad installed on any drive (D:, E:, ...) is found: the registry, KiCad's uninstall entries, every fixed drive's `Program Files\KiCad` and `\KiCad`, then `kicad-cli` on PATH (from 1.0.95, wiki #137). `kicad_readiness` says which install it found and where.--**Readiness before you drive:**-- `kicad_readiness '{}'`: read-only, zero side effects: is KiCad installed/ready? (`{detected, ready, versionCount, defaultVersion}`.) There's **no prewarm**: KiCad is a host app you install on request via `kicad_upgrade`, not a downloaded runtime asset.-- `kicad_describe '{}'`: the full machine-readable verb catalog (the completeness escape hatch; the per-call `_hint`s are what carry you in practice).-- `kicad_launch '{}'`: cold-start KiCad's project manager when nothing is running (idempotent, opens in the background). NOTE: `kicad_open_editors` only LISTS editors in a running KiCad, it never launches; use `kicad_launch` for "open KiCad".-- `kicad_uninstall '{}'`: remove every artifact the bridge created on this machine (plugin, Adom libraries, mesa fallback, caches). Part of Adom Bridge's uninstall cascade; `dryRun:true` previews.-- For **"what EDA tools do I have"** across all bridges, use Bridge's `bridge_readiness` (see below).--## Installing KiCad on a fresh machine (hard-won, v0.9.26+)--`kicad_upgrade` downloads the official downloads.kicad.org installer and silent-installs it. Two things that WILL bite on a truly fresh machine, both handled for you now:--- **Install scope is mandatory in silent mode.** KiCad 10's installer is NsisMultiUser: bare `/S` exits instantly with **`rc=666660`** ("invalid command-line parameters") and installs nothing. The bridge auto-picks the scope: **`/allusers`** when the Bridge process is elevated (installs to Program Files), else **`/currentuser`** (installs to `%LOCALAPPDATA%\Programs\KiCad`: **no admin, no UAC prompt at all**). Force it with `kicad_upgrade '{"scope":"user"}'`: that's your answer to *"install KiCad without a UAC prompt."* (Elevation note: on a VM/kiosk the Bridge process may already be elevated even though nobody clicked UAC, an elevated installer that launches the app hands down its admin token. `kicad_upgrade '{"diagnoseOnly":true}'` reports the real token/UAC facts.)-- **Library installs work before KiCad's first launch.** `%APPDATA%/kicad/<ver>/` (the per-user lib tables) doesn't exist until KiCad's first GUI run, so `kicad_install_library`/`_footprint` used to fail *"config directory not found."* The bridge now bootstraps that config (seeding the lib tables from the install template) automatically, installs just work on a never-launched KiCad.- - From 0.9.278: handed a single-part `.kicad_sym`, it still succeeds but returns `singlePartFile: true` and a hint naming `kicad_install_symbol {"filePath": ...}`, because that call registers the PART as its own library and leaves Adom untouched (wiki #72).--**GPU-less hosts (VM/RDP/server):** KiCad pops a blocking *"Could not use OpenGL"* dialog and the heavy editors may not render. The bridge auto-expires that dialog and switches KiCad to the Cairo software canvas, see **[kicad-interaction](skills/kicad-interaction/SKILL.md)** for the dialog/canvas details.--## Multi-version (KiCad 9 + KiCad 10 side-by-side)--Users can have multiple KiCad versions installed at the same time. The bridge auto-discovers everything under `C:/Program Files/KiCad/<version>/` and **defaults to the newest installed version** if you don't specify one.--```bash-# Discover what's installed (run this first if unsure)-adom-bridge kicad_list_versions '{}'-# → {"versions":[{"version":"10.0", "default":true, ...}, {"version":"9.0", ...}], "default":"10.0", "count":2}-```--Every `kicad_open_*` (and `install_*`, `run_drc`) command accepts an optional **`kicadVersion`** arg to pin a specific install:--```bash-# Open in KiCad 10 (newest: default if you omit kicadVersion)-adom-bridge kicad_open_board '{"filePath":"C:/designs/foo.kicad_pcb"}'--# Open in KiCad 9 explicitly (e.g. to check that an older project still works)-adom-bridge kicad_open_board '{"filePath":"C:/designs/foo.kicad_pcb","kicadVersion":"9.0"}'-```--If you pass a version that's not installed, the bridge returns a hint listing the installed versions. The success response includes `kicadVersionUsed` so you can confirm which install actually ran.--> **Note on libraries / config**: each KiCad version has its own `sym-lib-table` and `fp-lib-table` (in `%APPDATA%/kicad/<version>/`). `kicad_install_library` writes to the table of whichever version was selected, so if the user installs a library and then asks "open it in KiCad 9", you may need to install it under both versions.- ## "What EDA tools do I have?": readiness & host-app detection -**KiCad is the user's HOST APP. Adom Bridge *detects* it, it won't silently auto-install it as a prewarm/dependency** (Bridge v1.9.47+). But if KiCad isn't found, **do NOT hand the user a manual download**: offer to install it for them and run `kicad_upgrade '{}'`. That verb installs the latest stable and **installs from scratch, not just upgrades**: on Windows from the official downloads.kicad.org installer (silent `/S`; may raise a UAC prompt), on Linux the Flathub app per user (`flatpak install --user`, no root; a distro package needs `sudo`, which the bridge never runs, so it gives the user the command). The distinction: Bridge-core never auto-installs a host app, but a user-requested, agent-run install is exactly right.+**KiCad is the user's HOST APP. Adom Bridge *detects* it, it won't silently auto-install it as a prewarm/dependency** (Bridge v1.9.47+). But if KiCad isn't found, **do NOT hand the user a manual download**: offer to install it for them and run `kicad_upgrade '{}'`. That verb installs the latest stable from the official downloads.kicad.org installer (silent `/S`, Windows only; may raise a UAC prompt) and **installs from scratch, not just upgrades**. The distinction: Bridge-core never auto-installs a host app, but a user-requested, agent-run install is exactly right. - To answer **"what EDA tools / host apps do I have?"** across every bridge, call the Bridge-level verb **`adom-bridge bridge_readiness '{}'`**: Bridge aggregates each bridge's host-app detection (KiCad, Fusion 360, …) into one report. Start here for any "do I have X installed?" question. - For **KiCad specifically**, `adom-bridge kicad_readiness '{}'` is a zero-side-effect read-only probe: `{hostApp, detected, ready, versionCount, defaultVersion, versions[]}`. It does not install the plugin or spawn subprocesses, and works even when KiCad is absent (`detected:false`). Use `kicad_list_versions` when you need the full per-version detail (paths, IPC API, lib tables). -## Commands--| CLI form | Action | Purpose | Key args |-|---|---|---|---|-| `kicad_list_versions` | `list_versions` | Enumerate every installed KiCad version with paths and which one is the default |: |-| `kicad_readiness` | `readiness` | Read-only host-app detection: is KiCad installed/ready? Zero side effects (no plugin load, no subprocess); works even when KiCad is absent. For cross-bridge "what do I have", use Bridge's `bridge_readiness` |: |-| `kicad_check_for_updates` | `check_for_updates` | Read-only: installed KiCad vs latest STABLE (from downloads.kicad.org / KiCad GitLab tags); flags when the install predates the modern IPC/kipy automation API and should be upgraded |: |-| `kicad_upgrade` | `upgrade` | Download the **official** KiCad installer from downloads.kicad.org and silent-install it (`/S`), **not winget** (winget lags the real release). May raise a UAC prompt to approve | optional `version`, `force` |-| `kicad_open_schematic` | `open_schematic` | Open a `.kicad_sch` in the Schematic Editor | `filePath`, optional `kicadVersion` |-| `kicad_open_board` | `open_board` | Open a `.kicad_pcb` in the PCB Editor | `filePath`, optional `kicadVersion` |-| `kicad_open_symbol_editor` | `open_symbol_editor` | Launch Symbol Editor (optional: open specific library/symbol) | `libraryName`, `symbolName`, optional `kicadVersion` |-| `kicad_open_footprint_editor` | `open_footprint_editor` | Launch Footprint Editor | `libraryName`, `footprintName`, optional `kicadVersion` |-| `kicad_open_3d_viewer` | `open_3d_viewer` | Show the board (or footprint) in 3D viewer | `editor` ("auto"/"pcb"/"fp", "auto" prefers the board when both editors are open, no need to close sub-editors), optional `kicadVersion` |-| `kicad_close_symbol_editor` | `close_symbol_editor` | Close Symbol Editor |: |-| `kicad_close_footprint_editor` | `close_footprint_editor` | Close Footprint Editor |: |-| `kicad_close_3d_viewer` | `close_3d_viewer` | Close 3D viewer |: |-| `kicad_close` | `close` | Close KiCad (all editors). From 0.9.347 an editor with unsaved work is NOT killed: the reply says errorCode unsaved_changes with the dialog text. `discardChanges:true` presses Discard for the caller; `force:true` is the only kill | optional `discardChanges`, `force` |-| `kicad_window_info` | `window_info` | Enumerate open KiCad windows (HWND, title, bounds, editor type) |: |-| `kicad_install_library` | `install_library` | Register a `.kicad_sym` + `.pretty/` pair in sym-lib-table + fp-lib-table | `libraryPath`, `libraryType`, `libraryName`, optional `kicadVersion` |-| `kicad_uninstall_library` | `uninstall_library` | Remove a library row from sym-lib-table / fp-lib-table (the inverse of install). `deleteFiles:true` also deletes the installed copy, only under `Documents/KiCad/<ver>/`; refuses `Adom` and KiCad's stock libraries | `libraryName`, optional `libraryType` (symbol/footprint/both), `deleteFiles`, `kicadVersion` |-| `kicad_install_symbol` | `install_symbol` | Add a single symbol to an existing library | `fileName`, `fileContent`, optional `kicadVersion` |-| `kicad_install_footprint` | `install_footprint` | Add a single footprint to an existing library | `library`, `footprint_path`, optional `kicadVersion` |-| `kicad_run_drc` | `run_drc` | Run DRC on the current board, return violations | `filePath`, optional `kicadVersion` |-| `kicad_fix_keyboard` | `fix_keyboard` | Workaround stuck-modifier bug after screen lock |: |-| `kicad_screenshot_all` | `screenshot_all` | Capture every open KiCad window (lossless PNGs) |: |-| `kicad_send_key` | `send_key` | Single key to an EXACT `hwnd` (e.g. Enter/Escape to a dialog). For modifier CHORDS ("ctrl+s", "alt+3") use Bridge's `desktop_press_key`: don't route chords here. From 1.0.2 `menu` fires a menu item by label substring on the window's menu bar, background, no focus change (see "PCM repositories on the wiki") | `hwnd`, `key` or `menu` |-| `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` |-| `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered |: |-| `kicad_adom_library_heal` | `adom_library_heal` | Reconcile the Adom library: `mode` merge (default) / repoint / prune; `dryRun` previews. From 0.9.279 `prune` drops symbols KiCad refuses | `mode`, `dryRun` |-| `kicad_rescan_libraries` | `rescan_libraries` | Make parts installed while an editor was open visible: refreshes the editor library tree in place (seconds, no restart). Pass `footprints:["Adom:NAME"]` to have each loaded in the Footprint Editor as proof (`verified`). `force:true` is the KiCad process restart, last resort | optional `footprints`, `force` |-| `kicad_ipc_api` | `ipc_api` | Read or flip KiCad's IPC API server switch that live routing needs (`{}` reports, `{"enable":true}` writes kicad_common.json, backup kept). KiCad reads it at start: close KiCad, enable, relaunch | optional `enable` |-| `kicad_model_check` | `model_check` | Read-only 3D model diagnostics (from 0.9.350): per (model ...) reference, where KiCad looks, whether the file exists and holds geometry, hidden flags, scale; coded warnings with hints. `kicad_show_3d_chip` / `show_3d_board {filePath}` attach a `models` summary and `_modelHint`. renderVerified is always false: the 3D frame is the render proof. A bare relative model path is never looked up next to the .kicad_mod | `library` + `footprintName`, or `boardPath` |-| `kicad_list_footprints` | `list_footprints` | Footprints per library, read from the fp-lib-table with URIs expanded like KiCad does (stock Resistor_SMD and friends included, from 0.9.343) | optional `library` |--## The `show_*` family: START HERE for "show me X"--**These are the verbs a user's words map onto, and they were missing from this-skill entirely until 2026-08-27.** When someone says *"show me the board in-3D"*, reach for `kicad_show_3d_board`, not a hand-rolled open+screenshot. Each-one opens the right editor, brings the right thing up, and returns a captured-frame, so a single call is both the action and the proof.--| CLI form | Shows | Key args | Pitfall (from the verb's own catalog) |-|---|---|---|---|-| `kicad_show_project` | The whole project in the Project Manager (top of the design tree) | `filePath` (.kicad_pro) | absolute Windows path; no `%VAR%` expansion |-| `kicad_show_schematic` | The schematic (2D) in the Schematic Editor | `filePath` (.kicad_sch) | absolute Windows path; no `%VAR%` expansion |-| `kicad_show_2d_board` | The 2D layout in the PCB Editor | `filePath` (.kicad_pcb) | absolute Windows path; no `%VAR%` expansion |-| `kicad_show_3d_board` | The whole board in 3D | `filePath` | the 3D render self-raises late on slow boxes; the focus guardian handles it |-| `kicad_show_3d_chip` | A single part in 3D (the footprint's 3D view) | `libraryName`, `footprintName` | software-OpenGL boxes render slowly; the window can self-raise late (guarded) |-| `kicad_show_symbol` | A schematic symbol in the Symbol Editor | `libraryName`, `symbolName` | the symbol must be in an INSTALLED sym-lib-table library |-| `kicad_show_footprint` | A footprint in the Footprint Editor | `libraryName`, `footprintName` | a cold Footprint Editor spawns a fresh pcbnew and loads every fp lib, allow 20-30s |-| `kicad_show_library` | A symbol/footprint library, no part loaded | `libraryName` | the library must be registered in the sym-/fp-lib-table |--Two things that are true of every `show_*` verb and are worth knowing once:--- **They open windows in the BACKGROUND** and never steal the user's focus. A- late self-raise is expected on slow boxes and is caught by the focus guardian.-- **A green `success` is not proof the right thing is on screen.** Issues #42 and- #62 were both "success:true while the PREVIOUS part is still displayed".- Check `resolvedSymbol` / the returned frame before you tell a user what they- are looking at, especially in a batch.--## Understanding a schematic: netlist & connectivity (0.9.262)--Read-only intelligence about the DESIGN itself, not the GUI. All four derive-from one headless `kicad-cli` netlist export, so they work with KiCad closed.--| CLI form | Purpose | Key args |-|---|---|---|-| `kicad_extract_netlist` | Every component + every net with its nodes. `includeNodes:false` for names/sizes only; `rawFormat` ("spice", "orcadpcb2", "kicadsexpr", ...) returns the raw netlist text for OTHER tools instead | `filePath` |-| `kicad_trace_net` | Every pin on one named net. A miss returns `candidates`, never an empty success | `filePath`, `netName` |-| `kicad_find_connections` | Everything one component connects to, pin by pin, with peer pins per net | `filePath`, `reference` |-| `kicad_analyze_connections` | Overview: suspected-unconnected pins, power nets, highest-fanout nets and components | `filePath` |--Unlabeled nets are auto-named (`Net-(R1-Pad2)`, `unconnected-(C1-Pad1)`); single-node-nets are usually a missed wire but can be intentional, `kicad_run_erc` is the-authoritative check and knows about no-connect flags.--## The board model (EDA Bridge Standard)--`kicad_routing_state {"model":"standard"}` reads the board in the shape every Adom EDA bridge answers (Altium,-Fusion): mm, y up, custom pads as rectangles, the outline with arcs, unconnected from the copper, a revision that only-moves with copper, parts or outline; live or from the file. Companions: `kicad_layer_stack`, `kicad_pour_state`,-`kicad_bodies`, `kicad_netlist`. The edit verbs take its revision, and its coordinates with `frame:"standard"`. All of-it is the `kicad-board-model` skill in this package.--## Board checks (from 1.0.67)--Return paths, copper balance, impedance widths, current capacity, net lengths and pair skew, 3D heights, part-orientation, free space, symbols against footprints and net classes from names, each from the file or live; thermal-vias and via fences placed after a DRC check; and `kicad_board_report`, one HTML page of every check with a picture.-From 1.0.68: designators placed clear of the pads, the BOM, JLCPCB's assembly tiers and a JLCPCB order in one call.-All of it is the `kicad-board-checks` skill in this package.--## Live routing (from 0.9.340)--Needs KiCad 10.0.1+ with Preferences > Plugins > Enable IPC API server turned on.-The release zip ships the IPC client (`routing_deps/`, from `requirements-routing.txt`),-so nothing has to be pip-installed on the desktop. The live verbs connect to the-exact `filePath` on the selected IPC endpoint (`socketPath` is optional) and edit-the unsaved board in the editor without GUI clicks.--| Verb | Purpose | Required args |-|---|---|---|-| `kicad_routing_state` | Live pads, copper, per-net connected pad groups and revision | `filePath` |-| `kicad_route_net` | Commit one segment, a polyline, or branches on one net as one native Undo step | `filePath`, `expectedRevision`, exact `net`, `points` or `paths` |-| `kicad_remove_route` | Remove specific unlocked copper items as one Undo step | `filePath`, `expectedRevision`, `itemIds` |-| `kicad_routing_validate` | KiCad DRC on the unsaved editor snapshot | `filePath` |-| `kicad_add_zone` (from 1.0.5) | Add copper zones (pours) as ONE Undo step: the zone is filled and DRC-checked on a snapshot first, then created and filled through the IPC API | `filePath`, `expectedRevision`, `net`, `layers`, `polygon` [[x,y],...] mm, optional `priority`, `clearance`, `minThickness`, `connection` thermal/solid, or `zones: [...]` |-| `kicad_zone_state` (from 1.0.5) | Every zone with KiCad's filled copper area per layer and the coverage (what an ablation process keeps) | `filePath`, optional `refill: true` |--Pours come after the routing closes: which nets get one, which never do, priorities and the offline gate are the `kicad-copper-pours` skill in this package. Read routing_state, choose waypoints, then route with its revision. Points may be-`"J1.1"`, `[x,y]`, or `{x,y,layer}`; `{pad:"J2.1",layer:"B.Cu"}` also works.-Coordinates are mm, +y down. A layer marker changes the outgoing layer at that-point using a through via, including at the final waypoint. Width defaults to-0.25 mm, viaSize to 0.6 mm and viaDrill to 0.3 mm. `paths` groups branches on-one net into one Undo step. Only through vias are supported.--`dryRun:true` returns the proposed geometry without editing. By default the-candidate must add no DRC errors relative to the original snapshot; `validate:false`-explicitly skips that preflight. Existing violations and unconnected items may-remain while routing is in progress; routing_validate is the final check.-Sibling on-disk project/rule files are copied for DRC; unsaved project setting-changes and external relative rule resources are not captured. DRC does not-repair clearance errors, choose paths, or shove existing copper. The caller is-the router: there is no autorouter and no electrical sign-off in these verbs.--Success returns itemIds and a fresh revision. `save:true` also saves; the default-is an unsaved live edit. Stale revisions are refused. After a timeout or an unknown-mutation result, inspect the board before deciding what to do; never blindly-replay a mutation. A postCommitError means the copper was committed but the-subsequent read or save failed. Revision checking is optimistic: avoid concurrent-manual edits while committing. Connectivity state that changes mid-read is refused.--### General routing workflow--A board is routed when `kicad_routing_validate` says 0 unconnected and 0 new errors; anything else is unfinished work, and the loop that finishes it (vias, rip-up, then back to placement and route again) is the `kicad-place-route-loop` skill in this package.--Use this workflow for ordinary board work, repair, and review with any Adom AI-caller. Recording and demonstration playback are optional, separate tasks; no-demo script, narrator, Codex package, or particular model is required to use the-bridge verbs.--1. Resolve the requested desktop through Adom Bridge with explicit target and- caller identity. Read runtime version, document path, current live revision,- IPC availability and existing edits. Preserve development pins and other- callers' work. Package skill version and desktop bridge runtime version are- different: verify both when a capability appears missing.-2. Inspect the schematic netlist, footprint pads, existing copper and project- rules before planning. Determine supply voltages, return paths, protection,- switching nodes and sensitive signals. Component ratings bound capability;- they do not establish actual load or simultaneous current. Obtain missing- load/duty, copper thickness and manufacturer constraints before making- current-capacity or insulation claims.-3. Choose widths, vias and clearances from those requirements. Prefer horizontal,- vertical and 45-degree trace segments with deliberate pad escapes. Do not- claim arbitrary-angle straight segments are curved routing; use curves only- if the discovered editor API supports them and the resulting geometry can- be checked. Minimize unnecessary vias and keep return paths continuous.-4. Inspect heat-producing devices, exposed pads and existing thermal vias before- allocating copper. Confirm the electrical net of each exposed pad from the- actual component documentation. Expand connected same-net copper where it- improves the thermal path without compromising isolation or routing. External- copper, inner-layer routing and thermal vias depend on the real stackup and- assembly process; copper area alone does not establish device temperature.-5. For copper-ablation manufacturing, retain useful connected copper with pours- where authorized. Respect required isolation, pad access, solderability and- switching-node constraints. Do not expand every net indiscriminately, add- floating islands by default, or interpret a tool's minimum kerf as adequate- electrical clearance. Zone creation requires a separately discovered supported- operation; route_net does not create pours.-6. Read routing_state, compute a path, dry-run it, and commit against that revision.- Group a logical net path with points/paths for a useful Undo operation. Keep- validation enabled for ordinary routing. remove_route uses explicit itemIds;- do not assume it supports dryRun. Read back the changed copper and revision.-7. Validate the final live board, then save when requested. Record remaining- errors, warnings and unconnected items separately, including inherited issues.- Successful routing means the edit succeeded, not that the whole PCB passed- electrical review or is ready to manufacture.--From runtime 0.9.341, routing validation refills disposable snapshot zones and-reports potentially capped DRC lists. Inspect zonesRefilled,-violationReportMayBeTruncated and unconnectedCountIsLowerBound. A capped count-is a lower bound, and drc_incomplete refuses an uncertifiable error comparison.-Do not disable validation to turn that refusal into a claimed success. Older-runtimes lacking these fields do not establish equivalent validation.--### First-run and diagnostic handling--Discover current verb schemas and read response hints and nested errors before-retrying. A timeout can leave an editor open or an edit committed. Inspect state-and dialogs first. Use kicad_errors, kicad_log_tail and targeted desktop_ui_tree-reads to capture the actual blocker; do not blindly press Escape or Cancel on-unknown dialogs. In particular, cancelling a setup wizard can open a second-confirmation. UIA can identify and invoke a benign control without coordinate-clicks, but KiCad may still raise itself: follow kicad-uia foreground etiquette.--KiCad 9/10 eeschema does not host the Python reverse-bridge plugin. For plugin-health, inspect kicad_bridge_status and ping a discovered pcbnew instance;-plugin_not_running for eeschema is not an installation failure. Live routing-uses the separate KiCad IPC API; a plugin ping alone does not prove IPC readiness.--Resolve stock libraries through the detected KiCad installation and configured-path overrides. Do not repair a resolution failure by moving stock libraries-into Documents. Report the requested library, resolved path, installed KiCad-version and exact response. For footprint-load failures, collect status, errors,-log_tail (errorsOnly:true), plugin_diagnose and list_footprints from the same run.--Test installation separately from first editor launch. Confirm host detection,-CLI version, actual editor readiness and required APIs. Only clear configuration-or uninstall when the user authorized it. Treat successful close with forceKilled-as forced termination, not graceful save/close. Preserve evidence and report-reproducible problems in the KiCad Bridge wiki issues, with explicit test skips.-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. Place for routability (escape bands around fine-pitch parts, each passive at the pin it serves, the crystal group at the oscillator pins): the rules and the go-back-and-move loop are in the `kicad-place-route-loop` skill. 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.-`hasCopper` means some track exists; it does not prove connectivity.-`kicad_add_track`, `kicad_add_via`, and `kicad_route` edit **closed board files**.-They refuse an open board, preserve a `.adom-bak` by default, and never invoke-File > Revert. Use the live verbs above for anything the user should watch.--## Schematic edit in place (native build, 1.0.0+)--KiCad 10 has no schematic API, so these verbs edit the `.kicad_sch` file on disk the way eeschema writes it, keep a `.bak` beside it, run ERC before and after through kicad-cli, and report the delta. The open Schematic Editor keeps its in-memory copy: the response's `reloadHint` names the reload (`kicad_close` on the file, then `kicad_open_schematic`), and saving from a stale editor overwrites the edit.--| Verb | What | Args |-|---|---|---|-| `kicad_sch_place_symbol` | Place a library symbol with its pins and instances block; returns the new `uuid` | `filePath`, `libId` ("Device:R"), `reference`, `at` [x, y] mm, `rotation`?, `unit`?, `value`?, `footprint`?, `libraryPath`? |-| `kicad_sch_wire` | A polyline wire | `filePath`, `points` [[x, y], ...] |-| `kicad_sch_label` | A label | `filePath`, `text`, `at`, `kind` local, global, hierarchical, power, `rotation`? |-| `kicad_sch_no_connect` | A no-connect flag on an open pin, so ERC stops reporting it | `filePath`, `pin` (`U1.12` or `U1.PA10`) or `at` |-| `kicad_sch_move` | Move a placed symbol by reference | `filePath`, `reference`, `to` [x, y] |-| `kicad_sch_set_property` | Change or add a property (Value, Footprint, ...) | `filePath`, `reference`, `name`, `value` |-| `kicad_sch_delete` | Delete any item by uuid | `filePath`, `uuid` |--Every response: `success`, `output`, `path`, `backup`, `uuid`, `erc {ran, before, after, delta}`, `reloadHint`. When the KiCad 11 schematic API lands the same verbs switch to it and the reload step disappears.--## Autorouting: `kicad_autoroute` (native build, 1.0.0+)--`{"filePath": B, "engine": "ai" | "freerouting", "nets"?: [...], "dryRun"?: true}`. Without `engine` it answers `engine_required` with both engines described: ask the user which they want before routing.--- **`ai` is the recommendation.** It returns a plan request (pads, nets with their pads, the live routing state when the PCB editor has the board open) and mutates nothing; you then route with `kicad_route_net`, one native undo step per trace, DRC-checked per step, and you can explain every trace. Frontier models route real boards this way (the recorded Astra runs on the adom/codex page rebuilt a 94-footprint board to zero unconnected items).-- **`freerouting`** is for the user who wants a deterministic pass. It is not installed by default: `kicad_freerouting {"action":"status"|"install"|"uninstall"}` fetches Freerouting's own bundle (88 MB download, 141 MB on disk, its own Java runtime inside, nothing installed on the PC, no UAC) into the bridge cache only when the user says yes, and removes it on its own. The full choice, the AI recipe and the verification rules are in the `kicad-autorouting` skill.--## PCM repositories on the wiki (from 1.0.2)--KiCad's Plugin and Content Manager (PCM) installs libraries and plugins from a "repository", which is three files behind anonymous HTTPS GETs: `repository.json`, `packages.json` and one zip per version. Every wiki page serves its files that way, so a wiki page IS a PCM repository, and a release is a version bump plus `adom-wiki repo push`. The bridge drives the same registry and the same 3rdparty folder KiCad's own dialog uses, so the two stay in agreement. The worked example, proven end to end on KiCad 10.0.5, is [docs/pcm.md](docs/pcm.md); its sample repository URL is `https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json` and its package identifier is `com.adom.kicad-bridge.sample-lib`.--| Verb | Args | Key output |-|---|---|---|-| `kicad_pcm_add_repository` | `url`, optional `name` | `added`, `alreadyRegistered`, `name`, `repositoryId`, `verified` (the repository.json was fetched and is one), `file`, `backup` (`kicad.json.adom-bak`), `repositories[]`, `restartNeeded` |-| `kicad_pcm_remove_repository` | `url` | `removed`, `backup`, `repositories[]`. Packages installed from it stay installed |-| `kicad_pcm_list` | optional `checkUpdates` (default true) | per package `version`, `latestVersion`, `updateAvailable`, `updateCheck` (`ok`, `pinned`, `repository_not_registered`, `package_not_in_repository`, `fetch_failed`), `repositoryUrl`; top-level `count`, `updatesAvailable`, `repositories[]` |-| `kicad_pcm_install` | `identifier`, optional `repositoryUrl`, `version`, `update`, `libPrefix`, `force` | `version`, `installedTo`, `symbolLibraries`, `footprintLibraries`, `restartNeeded`; with `update:true` either `upToDate:true` or `previousVersion`, `version`, `updated:true` |-| `kicad_pcm_uninstall` | `identifier` | `removedDirs`, `recordRemoved`, `restartNeeded` |--The flow, in the order a user asks for it:--1. **Register**: `kicad_pcm_add_repository '{"url": REPO_URL}'`. Idempotent (`alreadyRegistered:true` on a second call). It edits `kicad.json`, which a running KiCad overwrites on exit, so do it with KiCad closed, or tell the user KiCad's own dialog (Plugin and Content Manager > Manage) is the alternative while it runs. `restartNeeded:true` is always reported: KiCad reads the list at start.-2. **Install**: `kicad_pcm_install '{"repositoryUrl": REPO_URL, "identifier": ID}'`. Newest version compatible with the detected KiCad unless `version` pins one; zip verified against `download_sha256`; unpacked under the 3rdparty root; the row written to `installed_packages.json` with the same `repository_id` KiCad computes, which is why KiCad's own dialog then shows the package as Installed. KiCad registers the `PCM_<name>` library rows itself on its next start (`pcm.lib_auto_add`).-3. **Check for updates**: `kicad_pcm_list '{}'`. `updatesAvailable` above 0 means a newer version is served; the per-package row says which (`latestVersion`) and `updateCheck` says why a check could not run.-4. **Update**, two ways, the user picks:- - **Headless**: `kicad_pcm_install '{"identifier": ID, "update": true}'`. Resolves the repository from the installed row (no `repositoryUrl` needed), replaces the version on disk and in the registry, answers `previousVersion`, `version`, `updated:true`. KiCad's own PCM dialog shows the new version the next time it opens (no restart), and an editor shows the new content on its next open. `upToDate:true` means nothing to do.- - **In KiCad**: tell the user that KiCad's PCM dialog shows the Update button on the package after a restart (its update check runs at startup, and the Refresh button inside an already open dialog does not recompute that flag). Update All, then Apply Pending Changes, then Close: KiCad writes `installed_packages.json` when the dialog closes, so `kicad_pcm_list` agrees only after Close.--To open the PCM dialog for a screenshot without touching the user's focus: `kicad_send_key '{"menu": "Plugin and Content Manager"}'` on the project manager (pass `hwnd` from `kicad_window_info` when more than one KiCad window is up), then `kicad_screenshot_all '{}'` captures the dialog as a child of that window. The same `menu` arg fires any menu item by label substring (`{"menu": "zoom to fit"}` on an editor). Two related facts from 1.0.2: the automatic dialog sweep leaves windows the user opened on purpose alone (the PCM dialog, "Applying Package Changes", Preferences, Manage Symbol/Footprint Libraries, Configure Paths, Board Setup, Schematic Setup, Symbol Fields Table, Design Rules), and `kicad_dismiss_dialogs '{"dismissAll": true}'` is the call that still closes them. Inside the PCM dialog, UI Automation reaches the tabs (`desktop_ui_select` with `role:"tab"`) and the "Update All", "Apply Pending Changes", "Refresh" and "Close" buttons (`desktop_ui_click`); the per-card Update button is not exposed to UIA.--Publishing your own repository (the packer, the layout, the release loop, and what bit): [docs/pcm.md](docs/pcm.md).--## Rescan after installs: `kicad_rescan_libraries`--Install N parts, call this once, then show them all (wiki #51). It reloads every open editor's library tree in place (seconds, nothing closed) and, given `footprints: ["Lib:Name", ...]`, loads each in the Footprint Editor as proof (`verified`, `probed[]`). `force: true` is the process restart and refuses while KiCad runs unless `confirmRestart: true`.--## Silkscreen and zone plans (from 1.0.22)--Stable-ID batch edits on the live board, each one native Undo step, each gated on a full-candidate DRC that counts new warnings as well as errors. All take `filePath` and `expectedRevision` (from `kicad_routing_state`), work in board-frame mm on either face, and answer with the IDs to use next time: never match by string.--| Verb | What it does |-| --- | --- |-| `kicad_text_bounds` | Read-only. Measures candidate silk text with KiCad's own text metrics (mirroring and justification change the box). Measure before you solve label positions. |-| `kicad_silk_text_batch` | `create` / `update` / `remove` standalone stroke-font `gr_text` on F.SilkS or B.SilkS. Footprint reference and value fields are refused here. |-| `kicad_silk_graphics_batch` | Lines, rectangles (2 points), arcs (start, mid, end) and Bezier leaders (4 points) on silk, solid stroke, unfilled. Locked or non-silk IDs refuse. |-| `kicad_silk_fields_batch` | Moves and resizes footprint Reference and Value fields by field UUID. KiCad 10.0.5 refuses this write over IPC: the verb answers `native_field_update_unsupported` and changes nothing. Treat that as final on that KiCad, do not retry. |-| `kicad_apply_zone_plan` | Reconciles a complete, explicitly owned zone plan (create, update, remove) in one Undo step. An unchanged plan answers `mutated:false, undoSteps:0` in about a second without running DRC. It never refills: `refillRequired` tells you to call `kicad_zone_state {refill:true}`, which answers an acknowledgement (`refillRequested`), not a finished fill. Poll `kicad_zone_state` for the result. |--Two contract points shared by all of them. A removal is verified after the commit (`deletionVerified`), because KiCad applies deletions at commit time. And `postCommitError` means the edit LANDED and only the follow-up read failed: read the state before you replay anything. None of them saves the board or redraws the 3D viewer unless asked; `kicad_refresh_board_views {pcbHwnd}` fires View > Refresh on that exact editor and its uniquely bound 3D viewer in the background, and answers `refreshRequested`, not proof of a finished render. Design notes: `docs/native-silk-text.md`, `docs/native-silk-graphics.md`, `docs/native-silk-fields.md`, `docs/native-zone-plan.md`, `docs/linked-view-refresh.md`.--`kicad_select_net {filePath, net}` selects every copper item of a net so the editor highlights it (then `kicad_send_key {hwnd, menu:"Zoom to Selection"}` frames it); `kicad_select_clear` undoes it. Selection is not an edit. `kicad_autoroute {reservePlanes:true}` (from 1.0.20) writes full-board plane layers to the router as power layers so it keeps signals off them; the reply carries `segmentsOnPlaneLayers` and `routed.widened` (returned wires widened to their net-class width).--## Driving the 3D viewer and menus without the mouse--`kicad_3d_view {action, count, pauseMs}` fires View-menu commands on the 3D Viewer by posted message: zoom, rotate, pan (`pan_left`, `pan_right`, `pan_up`, `pan_down`), view orientation, flip, or `action:"tour"`. No focus, no cursor, works with the viewer buried, and a window recording still sees every frame. `count` repeats with `pauseMs` between, so a slow orbit is one call (keep `pauseMs` at 500 or more on a big board). `kicad_menu_items {hwnd, contains}` lists a window's real menu entries first: labels differ between KiCad versions.--**Bodies missing in 3D? Check the viewer's own toggles before you bisect the board (from 1.0.33).** `3d_viewer.json` can hide whole classes of footprint bodies (SMD, through-hole, virtual, not-in-POS, DNP) in the viewer AND in `kicad-cli pcb render`, so a board whose every model resolves can still show pads only. `kicad_model_check` reports the flags, the count of modelled footprints per class, and a `_viewerHint` when a hidden class exists on the board; the 3D show verbs carry the same as `_modelHint`. `kicad_3d_viewer_settings {}` reads them and `{"smd": true}` sets one. Setting refuses with `kicad_running` while KiCad is up, because KiCad rewrites the file from memory on exit. These are the USER's preferences: change them because they asked or the proof needs it, and say that you did. The show verbs also report `titleConfirmed`: false means the window title never showed the board you asked for, so do not present that frame as proof.--## Window placement, the taskbar and foreground notices--- **Placement memory (from 1.0.14).** The bridge remembers where the user keeps each editor kind (`~/.adom/kicad-bridge-placement.json`) and puts a new or restarted frame back there; replies say `placementRestored`. Do not re-tile windows yourself after a restart.-- **`kicad_prewarm` (from 1.0.52).** Adom Bridge calls it once after each install or update (bridge.json `prewarm.verb`): detects KiCad (warming kicad-cli), seeds the first-run config, marks KiCad's setup wizard done so it never pops up, and repairs the taskbar shortcuts. Idempotent; the config steps are skipped while KiCad runs.-- **`kicad_taskbar_repair` (from 1.0.30).** Standalone PCB and schematic editors showed a blank white taskbar icon because KiCad's installer leaves the identity blank on those Start Menu shortcuts. The bridge writes KiCad's OWN identity onto them at startup; the verb runs it on demand and reports what it changed. It never invents an identity of its own.-- **`backgroundGuard` (from 1.0.42, default OFF from 1.0.50).** On: a KiCad window the bridge is opening right now is sent behind the user's window; any window that already existed is the user's to raise. Off: the bridge moves no KiCad window at all. Off by default while it is tuned (wiki #114). `kicad_set_settings {"backgroundGuard": true}` or the dashboard's Keep KiCad in the background switch.-- **`foregroundToastMinutes` (from 1.0.31, default 10, `0` = never).** When a verb the AI asked for must take the foreground, the user gets one toast per that many minutes, not one per call. Set it with `kicad_set_settings {"foregroundToastMinutes": 0}`; the 3 s on-screen caption on the driven box stays either way.-- A failed editor close now answers with `dialogsSeen` and `editorCloseVerbs`: quote them when you report a stuck close.--## Progress and operations--`kicad_progress {}` returns `active[]` frames (phase, stepLabel, percent, ETA, confidence, a `stepShot` of the phase's window) while a slow verb runs; `kicad_status.operations` lists active and recent runs (`yours` filters to your thread); `kicad_verb_times {}` gives per-verb timing stats (count, p50, p90, failures). The native bridge serves these on their own worker threads, so a poll answers in tens of milliseconds during a long open or a routing commit.- ## The native build (1.0.0+) -From 1.0.0 the bridge is one executable per OS (Windows, and Linux from the same zip) with no runtime to provision: `kicad_readiness.platform` reports what this build can do (`windowControl`, `uiAutomation`, `dialogSweep`, `focusEtiquette`, `ipcApi`, `silentInstall`, and on Linux `session`: `wayland` or `x11`) and a verb the OS cannot serve yet answers `not_supported_on_platform` with the owner named. Every window verb reports the rung it used as `mechanism` (`menu`, `uia`, `post`, `spawn`, `existing`, `ipc`). Two facts worth knowing: KiCad runs one IPC API server per machine and the first KiCad process takes it, so a standalone PCB editor opened beside the project manager has none (`no_pcb_frame` says so and names the remedy); and the in-KiCad Python plugin is retired (`kicad_bridge_status`, `kicad_bridge_call`, `kicad_install_plugin` answer `retired: true`), which is what keeps this bridge working when KiCad 11 removes SWIG scripting.--### On Linux--The same verbs run on a Linux desktop (Wayland or X11; KiCad 10 from the distro, the PPA, snap or Flatpak). KiCad's windows are read from the X server (XWayland on Wayland), screenshots come from the window's own pixels (`capturedBy: "local:x11"`), and menus, dialog text and buttons go through the accessibility bus (AT-SPI), all in the background. Differences an AI should know: a single key (`kicad_send_key {key:"escape"}`) reaches KiCad only when its window already has focus (`not_focused` otherwise; chords and `menu` run the menu item in the background instead); `kicad_click` is not available; a modal dialog opened by the bridge's own menu or button press cannot be read from the bus, so read it from its screenshot (`kicad_teardrops` is not Linux-ready for that reason); `kicad_simulate` needs the distribution's libngspice. Details and the per-verb table: `docs/linux.md` on this page.+From 1.0.0 the bridge is one Windows executable with no runtime to provision: `kicad_readiness.platform` reports what this build can do (`windowControl`, `uiAutomation`, `dialogSweep`, `focusEtiquette`, `ipcApi`, `silentInstall`) and a verb the OS cannot serve yet answers `not_supported_on_platform` with the owner named. Every window verb reports the rung it used as `mechanism` (`menu`, `uia`, `post`, `spawn`, `existing`, `ipc`). Two facts worth knowing: KiCad runs one IPC API server per machine and the first KiCad process takes it, so a standalone PCB editor opened beside the project manager has none (`no_pcb_frame` says so and names the remedy); and the in-KiCad Python plugin is retired (`kicad_bridge_status`, `kicad_bridge_call`, `kicad_install_plugin` answer `retired: true`), which is what keeps this bridge working when KiCad 11 removes SWIG scripting. ## Exports, linting and settings--| CLI form | Purpose | Key args |-|---|---|---|-| `kicad_export_step` | STEP 3D model from a board | `filePath` |-| `kicad_export_svg` | SVG plot from a board/schematic | `filePath` |-| `kicad_export_pdf` | PDF plot from a board/schematic | `filePath` |-| `kicad_export_gerber` | Gerbers from a board (via kicad-cli) | `filePath` |-| `kicad_export_bom_csv` | BOM CSV from a schematic | `filePath` |-| `kicad_export_part` | One-off single-part export: a `.kicad_sym` with ONLY that symbol, a `.pretty` with ONLY that footprint | `libraryName`, part name |-| `kicad_export_molecule` | One-shot molecule pack: STEP + silk renders + sch/pro discovery (Adom molecule pipeline) | project/part |-| `kicad_lint_board` | DRC pre-check on a `.kicad_pcb` (kicad-cli, read-only) | `filePath` |-| `kicad_lint_schematic` | ERC pre-check on a `.kicad_sch` (kicad-cli, read-only) | `filePath` |-| `kicad_lint_library` | Lint a symbol/footprint library. From 0.9.279 `kicadParser` is KiCad's OWN verdict (`kicad-cli sym export svg`, read-only); `ok:false` when KiCad refuses a file the structural check passed | `libraryPath` |-| `kicad_run_erc` | Run ERC on a schematic (read-only) | `filePath` |-| `kicad_format_upgrade` | Upgrade a file to the current KiCad file format | `filePath` |-| `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 on a closed board file (see "Offline copper editing") | `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` |--## Diagnostics and internals--| CLI form | Purpose |-|---|---|-| `kicad_status` | Bridge/host-app status, the verb Bridge polls after a non-terminal timeout |-| `kicad_progress` | Structured progress for a long-running background job |-| `kicad_verb_times` | Measured per-verb timing history for THIS machine |-| `kicad_diagnostics` | Read-only self-inspection (env, detection breakdown, processes, path probe) |-| `kicad_plugin_diagnose` | Why is the reverse-bridge plugin not loading? One call, every artifact |-| `kicad_install_plugin` | Install the reverse-bridge plugin into KiCad (idempotent) |-| `kicad_bridge_status` | Probe running KiCad plugin instances (one row per live editor process) |-| `kicad_bridge_call` | Low-level IPC into a live pcbnew/eeschema, `bridge_status` FIRST to find a live instance |-| `kicad_demo` | Six-beat showcase tour: staged, or a background job with percent/ETA progress |--## Quick examples--```bash-# Open a schematic-adom-bridge kicad_open_schematic '{"filePath":"C:/designs/foo.kicad_sch"}'--# List open KiCad windows-adom-bridge kicad_window_info '{}'--# Screenshot every KiCad window-adom-bridge kicad_screenshot_all '{}'--# Run DRC-adom-bridge kicad_run_drc '{"filePath":"C:/designs/foo.kicad_pcb"}'--# Install a library pair-adom-bridge kicad_install_library '{- "libraryName":"my-lib",- "libraryPath":"C:/libs/my-lib.kicad_sym",- "libraryType":"symbol"-}'--# Show a part in 3D (one call = the action AND the proof frame)-adom-bridge kicad_show_3d_chip '{"libraryName":"Adom","footprintName":"QFN-56-1EP_7x7mm"}'-```--## Error shape--Every failing response includes a `_hint` field with a human-readable next step, surface it to the user verbatim when a KiCad command fails.--## Bridge architecture (v1.8.31+): you don't need to know any port--Earlier versions of the KiCad bridge listened on hardcoded port `8772`. **As of v1.8.31, bridges use OS-assigned ephemeral ports**: every spawn gets a new free port. You never need to know it.--- The CLI (`adom-bridge kicad_*`) automatically routes through adom-desktop's direct API (default port 47200 as of v1.8.33, with auto-fallback to 47201-47209 if 47200 is taken). adom-bridge then forwards to the bridge's actual runtime port.-- The bridge port is internal plumbing and can change every spawn. `bridge_list` reports it as `spawn.runtimePort` for debugging only, do NOT hardcode that anywhere.-- Why: this lets adom-bridge coexist with Hydrogen (which used to claim 8772 itself) and any other tool that grabs ports in the 8000 range. Side-by-side bridges from different apps no longer collide.-- The relay path (Docker → wss proxy → Windows GUI) is unchanged, same `adom-bridge kicad_*` verbs, same JSON shape.--If you ever find yourself wanting to talk directly to the KiCad bridge process: don't. Go through the verb dispatch. If the verb you need doesn't exist, request a feature in `adom-inc/adom-desktop`.--## Related--- `adom-desktop-discovery` (gallia), ensures the CLI + relay are installed.-- `adom-desktop-fusion`: sibling bridge for Fusion 360.-- `adom-desktop-direct-api`: the direct API contract (port 47200 + 47201-47209 fallback, discovery file at `~/.adom/direct-api-port`).-- Repo: `adom-inc/adom-desktop/plugins/kicad/`--## KiCad's verdict vs the bridge's reader (from 0.9.279)--A `.kicad_sym` the bridge's reader balances can still be one KiCad refuses, and KiCad then lists that library EMPTY in the Symbol Editor (one malformed symbol hides the whole file). `kicadParser` on `kicad_adom_library_status`, `kicad_lint_library`, install results and the `kicad_show_library` error is KiCad's own answer: `ok`, `symbols` (KiCad's inventory), `error` (KiCad's words). `kicad_install_symbol` and `kicad_install_library` refuse a file KiCad refuses (`errorCode kicad_refuses_symbol` / `kicad_refuses_library`) and write nothing. If a library is already poisoned: `kicad_adom_library_heal {"mode":"prune","dryRun":true}` names the culprit(s); without `dryRun` it rebuilds from the symbols KiCad accepts and keeps the original as `.pruned.bak`.--### Design rules by fab (from 0.9.283, wiki #73)--Rules live on the wiki page adom/pcb-design-rules and are fetched at call time, so a rule change is a wiki publish, not a bridge release.--- `kicad_list_design_rules {"fab": "jlcpcb"}` (fab optional): fabs, processes, layer counts, file URLs. The 3rd party fab (the default fab id) is the baseline and is listed first.-- `kicad_set_design_rules {"fab": "jlcpcb", "layers": 2, "boardPath": "C:/.../board.kicad_pcb"}`: downloads the fab's `.kicad_dru`, stages it as `<board stem>.kicad_dru` (KiCad loads that name automatically; an existing file is backed up next to it), then runs DRC with it and returns the violations. `layers` defaults to the board's copper count, `druPath` uses a local file instead, `runDrc:false` only stages, `keepExisting:true` refuses to replace.--### Symbol names vs file names (from 0.9.283, wiki #62)--- `kicad_list_symbols {"libraryName": "Adom"}` (default Adom, or `libraryPath`): the names inside the library, plus `kicadParser.symbols` (what KiCad itself loads).-- `kicad_show_library` resolves an MPN or file name to the real symbol name (exact, then case-insensitive, then a unique prefix match) and reports `aliasedFrom` / `aliasHow`; an ambiguous or missing name is stated in `aliasNote`.--### Real content to try (the dashboard's "Say it" phrases point here)--- **A full wiki part page**: `ray/xl555` (XL555 timer: `xl555.kicad_sym`, `xl555.kicad_mod`, `xl555.step`, `xl555.glb`). Fetch its files with `adom-wiki repo show ray/xl555 <file>` (or the files API), `push_file` them to the desktop, then `kicad_install_symbol` / `kicad_install_footprint` (or `kicad_install_library_bundle` for a zip) and `kicad_show_library` / `kicad_show_footprint`.-- **Adom boards with a schematic and a PCB**: `adom/drone-motor-carrier`, `adom/esc-g431`, `adom/carrier-fc-rp2350a-mini`, `adom/rp2350a-ads1220` (each carries `<slug>.kicad_sch`, `<slug>.kicad_pcb`, `.step`, `.glb`). Pull the `.kicad_sch` + `.kicad_pcb`, push them to the desktop, then `kicad_open_board` / `kicad_show_2d_board` / `kicad_show_3d_board`, `kicad_lint_board`, `kicad_set_design_rules {}` (no fab: the baseline fab), `kicad_export_gerbers`.-- **The Adom PCM libraries** (symbols, footprints and 3D for every part on the Adom line): `kicad_pcm_install {"repositoryUrl":"https://wiki.adom.inc/api/v1/pages/kicad-pcm/files/repository.json","identifier":"inc.adom.kicad.basic-parts"}` (also `inc.adom.kicad.rp2040`). KiCad registers the libraries on its next start.-- Never quote a wiki page you have not fetched: `aravk/in-s42ata`, an older example, no longer exists.--- **kicad-bridge-contributing**: you hit a bug or a missing verb: file it with the verbatim reply, disclose any workaround, and how to clone this page's repo, build, test and send a branch PR that gets merged within minutes.
bridge.json+4−470@@ -1,481 +1,15 @@-{- "manifest_version": 1,- "name": "kicad",- "displayName": "KiCad EDA",- "version": "0.9.351",- "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.",- "homepage": "https://wiki.adom.inc/adom/adom-bridge",- "author": "Adom Inc.",- "license": "MIT",- "spawn": {- "kind": "python",- "entrypoint": "server.py",- "port": 0,- "healthEndpoint": "/status",- "stopMethod": "kill",- "killImageName": "python.exe"- },- "verbPrefixes": [- "kicad_"- ],- "verbs": [- "kicad_describe",- "kicad_status",- "kicad_readiness",- "kicad_list_versions",- "kicad_diagnostics",- "kicad_run_drc",- "kicad_run_erc",- "kicad_export_gerber",- "kicad_export_pdf",- "kicad_export_svg",- "kicad_export_pos",- "kicad_render_board",- "kicad_export_step",- "kicad_export_3d",- "kicad_import_design",- "kicad_import_step",- "kicad_export_bom_csv",- "kicad_uninstall",- "kicad_extract_netlist",- "kicad_netlist",- "kicad_trace_net",- "kicad_find_connections",- "kicad_analyze_connections",- "kicad_lint_board",- "kicad_lint_schematic",- "kicad_lint_library",- "kicad_format_upgrade",- "kicad_list_symbols",- "kicad_list_footprints",- "kicad_adom_library_status",- "kicad_get_settings",- "kicad_set_settings",- "kicad_list_design_rules",- "kicad_set_design_rules",- "kicad_3d_viewer_settings",- "kicad_taskbar_repair",- "kicad_prewarm",- "kicad_model_check",- "kicad_pcm_list",- "kicad_pcm_install",- "kicad_pcm_uninstall",- "kicad_pcm_add_repository",- "kicad_pcm_remove_repository",- "kicad_check_for_updates",- "kicad_upgrade",- "kicad_tool_update",- "kicad_sch_place_symbol",- "kicad_sch_place_part",- "kicad_sch_wire",- "kicad_sch_label",- "kicad_sch_move",- "kicad_sch_delete",- "kicad_sch_set_property",- "kicad_sch_edit",- "kicad_sch_no_connect",- "kicad_sch_components",- "kicad_sch_pins",- "kicad_sch_items",- "kicad_sch_rotate",- "kicad_sch_move_region",- "kicad_sch_annotate",- "kicad_sch_replace_symbol",- "kicad_sch_swap_part",- "kicad_sch_update_from_library",- "kicad_sch_reset_fields",- "kicad_sch_delete_wire",- "kicad_sch_split_wire",- "kicad_sch_edit_label",- "kicad_sch_junction",- "kicad_sch_connect_to_net",- "kicad_sch_connect",- "kicad_sch_connect_pins",- "kicad_sch_text",- "kicad_sch_bus",- "kicad_sch_bus_entry",- "kicad_sch_connect_to_bus",- "kicad_sch_batch",- "kicad_sch_create",- "kicad_sch_page",- "kicad_sch_sheet",- "kicad_sch_hierarchy",- "kicad_sch_sheet_pins",- "kicad_sch_trace_point",- "kicad_sch_check",- "kicad_sch_fix_connectivity",- "kicad_render_schematic",- "kicad_design_review",- "kicad_ipc_api",- "kicad_export_molecule",- "kicad_errors",- "kicad_log_tail",- "kicad_bridge_status",- "kicad_bridge_call",- "kicad_install_plugin",- "kicad_plugin_diagnose",- "kicad_install_library",- "kicad_uninstall_library",- "kicad_install_symbol",- "kicad_install_footprint",- "kicad_install_library_bundle",- "kicad_adom_library_heal",- "kicad_make_part_project",- "kicad_export_part",- "kicad_silk_fields_batch",- "kicad_silk_graphics_batch",- "kicad_text_bounds",- "kicad_silk_text_batch",- "kicad_routing_state",- "kicad_route_net",- "kicad_remove_route",- "kicad_add_zone",- "kicad_apply_zone_plan",- "kicad_zone_state",- "kicad_select_net",- "kicad_select_clear",- "kicad_remove_zone",- "kicad_routing_validate",- "kicad_board_pads",- "kicad_add_track",- "kicad_add_via",- "kicad_route",- "kicad_autoroute",- "kicad_freerouting",- "kicad_layer_stack",- "kicad_pour_state",- "kicad_bodies",- "kicad_board_summary",- "kicad_list_nets",- "kicad_net_connections",- "kicad_list_designators",- "kicad_copper_layers",- "kicad_layers_in_use",- "kicad_component_data",- "kicad_component_pins",- "kicad_board_outline",- "kicad_set_layer_count",- "kicad_delete_graphics",- "kicad_add_mounting_hole",- "kicad_import_svg",- "kicad_refill_zones",- "kicad_save_board",- "kicad_add_footprint",- "kicad_flip_footprint",- "kicad_delete_footprint",- "kicad_edit_footprint",- "kicad_align_footprints",- "kicad_update_footprints_from_library",- "kicad_swap_footprint",- "kicad_edit_pours",- "kicad_remove_items",- "kicad_plate_vias",- "kicad_normalize_bodies",- "kicad_symbol_mode",- "kicad_add_symbol_art",- "kicad_cleanup_libs",- "kicad_build_symbol_modes",- "kicad_license",- "kicad_signin",- "kicad_connect_workspace",- "kicad_agent_start",- "kicad_agent_stop",- "kicad_agent_status",- "kicad_agent_recover",- "kicad_selftest_script_error",- "kicad_extension_ping",- "kicad_install_extension",- "kicad_install_receipt",- "kicad_footprint_graphics",- "kicad_footprint_models",- "kicad_get_selection",- "kicad_select_items",- "kicad_cross_probe",- "kicad_placement_state",- "kicad_move_footprint",- "kicad_placement_validate",- "kicad_refresh_board_views",- "kicad_launch",- "kicad_open_board",- "kicad_open_schematic",- "kicad_open_symbol_editor",- "kicad_open_footprint_editor",- "kicad_menu_items",- "kicad_3d_view",- "kicad_open_3d_viewer",- "kicad_open_editors",- "kicad_close",- "kicad_close_symbol_editor",- "kicad_close_footprint_editor",- "kicad_close_3d_viewer",- "kicad_dismiss_dialogs",- "kicad_window_info",- "kicad_state",- "kicad_screenshot_all",- "kicad_send_key",- "kicad_click",- "kicad_fix_keyboard",- "kicad_place_footprint",- "kicad_enable_software_opengl",- "kicad_rescan_libraries",- "kicad_show_project",- "kicad_show_symbol",- "kicad_show_footprint",- "kicad_show_3d_chip",- "kicad_show_schematic",- "kicad_show_2d_board",- "kicad_show_3d_board",- "kicad_show_library",- "kicad_progress",- "kicad_verb_times",- "kicad_export_dxf",- "kicad_export_gencad",- "kicad_export_ipc2581",- "kicad_export_odb",- "kicad_export_ipc356",- "kicad_export_fab_package",- "kicad_validate_for_fab",- "kicad_estimate_cost",- "kicad_project_create",- "kicad_project_info",- "kicad_project_rename",- "kicad_project_snapshot",- "kicad_fp_create",- "kicad_fp_edit",- "kicad_fp_info",- "kicad_sym_create",- "kicad_sym_delete",- "kicad_sym_info",- "kicad_search_library",- "kicad_netclasses",- "kicad_board_rules",- "kicad_custom_rules",- "kicad_modify_track",- "kicad_route_diff_pair",- "kicad_copy_routing",- "kicad_check_clearance",- "kicad_score_placement",- "kicad_place_decoupling",- "kicad_plan_fanout",- "kicad_update_pcb_from_schematic",- "kicad_demo",- "kicad_net_classes",- "kicad_repour",- "kicad_save",- "kicad_update_pcb",- "kicad_annotate",- "kicad_create_symbol",- "kicad_create_footprint",- "kicad_read_symbol",- "kicad_remove_symbol",- "kicad_sch_sheet_symbol",- "kicad_selection",- "kicad_route_pair",- "kicad_fab_check",- "kicad_find_symbol",- "kicad_export_manufacturing",- "kicad_add_rule",- "kicad_render_board_3d",- "kicad_remove_parts",- "kicad_edit_parts",- "kicad_align_parts",- "kicad_remove_graphics",- "kicad_read_footprint",- "kicad_export_library_part",- "kicad_create_part_project",- "kicad_add_body",- "kicad_clone_symbol",- "kicad_copy_footprint",- "kicad_doc_params",- "kicad_layers",- "kicad_part_parameters",- "kicad_place_on_sch",- "kicad_remove_footprint",- "kicad_rename_footprint",- "kicad_rename_symbol",- "kicad_ripup_nets",- "kicad_set_params",- "kicad_set_visibility",- "kicad_sheet_params",- "kicad_variants",- "kicad_sch_zoom",- "kicad_animate_view",- "kicad_silk_state",- "kicad_symbol_prims",- "kicad_xcopy_footprint",- "kicad_compile",- "kicad_diff_pairs",- "kicad_list_installed_libraries",- "kicad_replicate_layout",- "kicad_teardrops",- "kicad_panelize",- "kicad_copper_balance",- "kicad_current_capacity",- "kicad_impedance",- "kicad_return_path",- "kicad_net_lengths",- "kicad_check_orientation",- "kicad_find_space",- "kicad_height_check",- "kicad_thermal_vias",- "kicad_auto_classes",- "kicad_pin_pad_check",- "kicad_via_fence",- "kicad_board_report",- "kicad_bom",- "kicad_assembly_check",- "kicad_jlcpcb_order",- "kicad_footprint_from_land_pattern",- "kicad_silk_designators",- "kicad_read_sheet_file",- "kicad_build_board",- "kicad_test_board",- "kicad_length_tune",- "kicad_silkscreen_prep",- "kicad_component_classes",- "kicad_build_schematic",- "kicad_set_layer_visibility",- "kicad_library_views",- "kicad_placement_check",- "kicad_plan_route",- "kicad_generate_body",- "kicad_export_spice",- "kicad_simulate",- "kicad_zoom_to",- "kicad_foreground",- "kicad_dialog_log",- "kicad_license_status",- "kicad_run_script",- "kicad_eval",- "kicad_schematic_review",- "kicad_cross_check",- "kicad_list_library",- "kicad_open_document",- "kicad_open_project",- "kicad_activate_document",- "kicad_render_board_layers",- "kicad_export_bom",- "kicad_routing_drc",- "kicad_set_track_width",- "kicad_footprint_primitives",- "kicad_rules_by_kind",- "kicad_document_info",- "kicad_review_board"- ],- "statusVerb": "kicad_status",- "timeouts": {- "default": 130,- "verbs": {- "upgrade": 900,- "run_drc": 300,- "run_erc": 300,- "export_step": 300,- "export_gerber": 210,- "export_pdf": 180,- "export_svg": 180,- "open_3d_viewer": 300,- "enable_software_opengl": 240,- "format_upgrade": 180,- "demo": 600,- "export_molecule": 420,- "freerouting": 900,- "autoroute": 900,- "prewarm": 120,- "library_views": 300,- "simulate": 300,- "run_script": 300,- "eval": 300- }- },- "dependencies": {- "python": ">=3.11",- "kicad": ">=7.0"- },- "category": "EDA",- "tags": [- "pcb",- "schematic",- "open-source",- "kicad-10",- "kicad-11-ready"- ],- "docs": "https://wiki.adom.inc/adom/kicad-bridge",- "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/kicad-bridge/files/adom-bridge-kicad-manifest.json",- "insidersManifestUrl": "https://wiki.adom.inc/api/v1/pages/insiders/files/kicad-bridge/version.json",- "detect": {- "hostApp": "KiCad",- "appPathsExe": "kicad.exe",- "paths": {- "windows": [- "%ProgramFiles%\\KiCad\\*\\bin\\kicad.exe",- "%ProgramFiles(x86)%\\KiCad\\*\\bin\\kicad.exe",- "%LocalAppData%\\Programs\\KiCad\\*\\bin\\kicad.exe",- "D:\\Program Files\\KiCad\\*\\bin\\kicad.exe",- "D:\\KiCad\\*\\bin\\kicad.exe",- "E:\\Program Files\\KiCad\\*\\bin\\kicad.exe",- "E:\\KiCad\\*\\bin\\kicad.exe",- "F:\\Program Files\\KiCad\\*\\bin\\kicad.exe",- "F:\\KiCad\\*\\bin\\kicad.exe",- "G:\\Program Files\\KiCad\\*\\bin\\kicad.exe",- "G:\\KiCad\\*\\bin\\kicad.exe"- ],- "macos": [- "/Applications/KiCad/KiCad.app" ], "linux": [ "/usr/bin/kicad",- "/usr/local/bin/kicad",- "/snap/bin/kicad",- "~/.local/bin/kicad",- "/var/lib/flatpak/exports/bin/org.kicad.KiCad",- "~/.local/share/flatpak/exports/bin/org.kicad.KiCad"+ "/usr/local/bin/kicad" ] }, "installVerb": "kicad_upgrade",- "hostAppOptionalVerbs": [- "readiness",- "describe",- "status"- ]- },- "prewarm": {- "verb": "kicad_prewarm",- "requiresHostApp": true,- "assets": [- "kicad-cli warm (detection)",- "KiCad first-run config + setup wizard marked done",- "KiCad's own taskbar shortcut identity"- ]- },- "platforms": {- "windows": {- "supported": true- },- "macos": {- "supported": false, "reason": "macOS port pending \u2014 kicad-cli verbs work, but the Win32 window-automation handlers (kicad_send_key, kicad_click, kicad_screenshot_all) need a Quartz/AppKit equivalent." }, "linux": {- "supported": true,- "notes": "Native build (kicad-bridge) in the release zip; see docs/linux.md."+ "supported": false,+ "reason": "Linux port pending \u2014 kicad-cli works, but Win32 window automation doesn't translate to X11/Wayland." } },- "watcher": {- "supports": true,- "displayName": "Project Watch",- "description": "Monitors a KiCad project folder for file changes and syncs them to Docker. Configured via the set_project_watch / trigger_project_watch verbs (AD core owns the watcher; the KiCad section of AD's Settings panel was removed in AD 1.9.117 \u2014 the config keys are still honored).",- "defaultGlobs": [- "*.kicad_pcb",- "*.kicad_sch",- "*.kicad_pro"- ],- "configKey": "project_watch"- },- "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",- "languages": [- "Python",- "PowerShell"- ],- "releasedAt": "2026-06-26T16:00:00Z",- "uninstall": "kicad_uninstall"-}+ "watcher": {
docs/comparison.md+4−222@@ -1,240 +1,22 @@-# How the KiCad Bridge compares--An audit of the notable AI and MCP automation tools for KiCad, refreshed on **2026-10-04** (the first audit was 2026-09-11) against each tool's public repository, README, release notes and open issues. We build the Adom KiCad Bridge, so read this as one maker's view, written to be checkable line by line. Every author in this field is pushing KiCad automation forward, and several are ahead of us in areas the page names plainly. If anything about your tool is wrong, file it on our [issue tracker](https://wiki.adom.inc/adom/kicad-bridge/issues) and we will correct it, usually the same day.--KiCad 11 (nightlies today, release expected around February 2027) changes this landscape for everyone, so each row of the matrix says what 11 does to it. The section [What KiCad 11 changes](#what-kicad-11-changes) has the detail.--## The field--| Tool | Stars | Last push | Latest release | License | How it reaches KiCad | Platforms |-|---|---|---|---|---|---|---|-| [Adom KiCad Bridge](https://wiki.adom.inc/adom/kicad-bridge) (ours) | n/a (wiki) | daily | 1.0.53 public (signed), 1.0.55 insiders | MIT | IPC API, kicad-cli, file edit, background Win32 and UI Automation window control, Adom Bridge for screenshots, recording and input | Windows x64 (macOS and Linux need an owner's build) |-| [KiCAD-MCP-Server](https://github.com/mixelpixx/KiCAD-MCP-Server) | 2,567 | 2026-10-01 | v2.8.2 (09-25) | MIT | SWIG and IPC, kicad-skip, kicad-cli, an opt-in in-process GUI driver, AT-SPI on Linux | Win, mac, Linux |-| [Konnect](https://github.com/mixelpixx/Konnect) | 869 | 2026-10-04 | v0.13.0 (10-02) | AGPL-3.0 or commercial | IPC for PCB, its own s-expression engine for schematics, kicad-cli | Win, mac (universal), Linux |-| [kicad-happy](https://github.com/aklofas/kicad-happy) | 1,330 | 2026-09-13 | v2.2.1 (09-01) | MIT | File parsing, read-only, no KiCad needed | all |-| [kicad-mcp](https://github.com/lamaalrajih/kicad-mcp) | 524 | 2025-10-17 | none | MIT | File parsing, kicad-cli, launches KiCad by subprocess | all |-| [Seeed kicad-mcp-server](https://github.com/Seeed-Studio/kicad-mcp-server) | 137 | 2026-09-28 | none | MIT (pyproject) | SWIG, text-parse fallback, kicad-cli | all |-| [kicad-mcp-pro](https://github.com/oaslananka/kicad-mcp-pro) | 117 | 2026-10-04 | v3.37.0 (10-02) | MIT | File parsing, kicad-cli, kipy IPC | all, plus a Tauri desktop app |-| [KiCad-AI-Assistant](https://github.com/paul356/KiCad-AI-Assistant) | 101 | 2026-10-04 | 0.2.6 (10-04) | MIT | In-KiCad action plugin with an embedded MCP server | Linux, Windows |-| [KiPilot](https://github.com/belaszalontai/kipilot-mcp) | 11 | 2026-09-17 | v0.3.0 (09-17) | Apache-2.0 | kipy IPC only, KiCad GUI running (schematic editing on KiCad 11 nightlies) | Windows zip, others from source |-| [mcp-server-kicad](https://github.com/ProductOfAmerica/mcp-server-kicad) | 11 | 2026-10-04 | v0.20.1 (08-15) | MIT | Byte-preserving file edits, kicad-cli, pcbnew for zone fill and Freerouting | all |--Also audited, not in the matrix:--- [eda-agent](https://github.com/salitronic/eda-agent) (237 stars, Apache-2.0, active): Altium first, with KiCad and EasyEDA backends and its own place-and-route engine since 09-30. It is the closest peer to our multi-tool approach. It is not a matrix column because its KiCad backend is secondary to Altium, and marking it row by row would be guesswork.-- [kicad-agentic-mcp](https://github.com/nevenfo/kicad-agentic-mcp) (5 stars): derived from Konnect, with a plan, compile, transaction and verdict architecture and a published benchmark.-- [kicad-harness](https://github.com/zxkmm/kicad-harness) (30 stars): an agent skill with SWIG and IPC recipes, region renders and datasheet footprints.-- [copperhead](https://github.com/copperheadhq/copperhead) (322 stars, Apache-2.0, v0.11.0): the fastest-growing new entrant. It goes from a brief to schematic, layout, Gerbers and firmware by editing KiCad files with a kicad-cli ERC and DRC loop, and edits an existing KiCad repository like a coding agent. It does not drive a running KiCad, so it sits with kicad-happy rather than in the matrix; its README is candid that the end-to-end loop is not yet proven.-- [kicad-tools](https://github.com/rjwalters/kicad-tools) (64 stars, MIT, v0.22.0): file-level tools with its own autorouter and placement optimizer (CUDA and Metal), a pure-Python DRC, JLCPCB integration and a demo gallery.-- [blwfish/kicad-mcp](https://github.com/blwfish/kicad-mcp) (23 stars): pcbnew through a subprocess bridge with Freerouting, and firmware-driven schematic generation.-- Idle since spring: [Huaqiu kicad-mcp](https://github.com/Huaqiu-Electronics/kicad-mcp) and [pcb-mcp](https://github.com/bunnyf/pcb-mcp).--Code-first tools that generate KiCad files rather than drive KiCad ([tscircuit](https://github.com/tscircuit/tscircuit), [atopile](https://github.com/atopile/atopile), Diode, JITX, Flux, Quilter, [DeepPCB](https://github.com/instadeepai/deeppcb-kicad-plugin)) are a different category and are not compared here.--## Credit where it is due--What each tool does best, in our honest reading. Several of these are things we do not do, or do not do as well.--- **KiCAD-MCP-Server** is the community's server: the most stars, the most outside contributors (v2.8.0 credits more than fifteen), and release notes precise enough to learn from. Its new opt-in GUI driver reaches menus, toolbars and dialogs on Windows and Linux.-- **Konnect** has the strictest correctness discipline in the field. Every mutation reports applied, refused or uncertain; evidence is labelled live, saved-file or CLI; releases are gated on real KiCad. It ships as one binary on three operating systems, installable from KiCad's own Plugin and Content Manager.-- **kicad-happy** has the deepest design review available (power tree, ESD, EMC, thermal) and is the only tool that generates and runs SPICE testbenches automatically, across ngspice, LTspice and Xyce with Monte Carlo. Its validation corpus is unmatched: 5,857 projects and 2.76 million checks, all but 9 passing. It works across Claude Code, Codex, Copilot CLI, Antigravity and opencode, and runs as a GitHub Action.-- **kicad-mcp** started this category, and many later servers learned from it.-- **Seeed kicad-mcp-server** does pin and pinmux analysis with device-tree and test-code generation, which nobody else does, and offers a free parts registry with no login.-- **kicad-mcp-pro** has the best engineering hygiene in the field: OpenSSF Best Practices Silver, SBOMs and checksums on releases, and a self-published parity matrix with its limits labelled. It also has the broadest analysis surface (signal integrity, power integrity, EMC, thermal, DFM).-- **KiCad-AI-Assistant** is the only tool whose chat lives inside KiCad itself, and it manages its own context budget carefully. Its documentation is bilingual.-- **KiPilot** was first to drive KiCad 11's schematic API live in the editor, with no file round-trip, and every mutation can be dry-run. It got there before we did.-- **mcp-server-kicad** has a byte-preserving write layer that refuses an edit rather than risk corrupting a file.--## Feature matrix--Y = yes, P = partial, N = no. Marks for other tools come from their own documentation; marks for ours come from the native catalog and our gate runs. The last column says what KiCad 11 does to the row.--| Column | Tool |-|---|---|-| Adom | Adom KiCad Bridge |-| MX | KiCAD-MCP-Server |-| KN | Konnect |-| HP | kicad-happy |-| LA | kicad-mcp |-| SD | Seeed kicad-mcp-server |-| PRO | kicad-mcp-pro |-| AIA | KiCad-AI-Assistant |-| KP | KiPilot |-| POA | mcp-server-kicad |--### Driving KiCad itself--| Feature | Adom | MX | KN | HP | LA | SD | PRO | AIA | KP | POA | With KiCad 11 |-|---|---|---|---|---|---|---|---|---|---|---|---|-| Tool catalog the AI reads, each verb with a hint, example, related verbs and pitfalls | Y (211) | Y (244) | Y (228) | N | P | P | Y (387) | Y (115) | Y (135) | P (109) | unchanged |-| One native binary, no runtime, installed and updated from a sha-verified manifest | Y (signed since 1.0.53) | N | Y | N | N | N | N | N | N | N | unchanged |-| Install KiCad itself, silently, then verify | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Launch KiCad, open a project or board in the GUI | Y | Y | P | N | Y | N | P | Y | P | N | `OpenDocument` helps everyone, headless only for now |-| Open the symbol, footprint, schematic and PCB editors and the 3D viewer at a named part | Y | N | N | N | N | N | N | N | N | N | `OpenLibraryItem` makes this easier for everyone |-| Keep KiCad out of the user's way | P (1) | N | N | n/a | N | N | N | N | N | N | unchanged: the editors still take focus |-| Screenshot of the real KiCad windows, dialogs included, while covered | Y | P (2) | N | N | N | N | N | N | N | N | unchanged |-| Video recording of KiCad or the whole desktop, while covered, with system audio | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Taskbar status: a badge while an AI drives the window, live progress on the button | Y | N | N | N | N | N | P (3) | N | N | N | unchanged |-| Dialog handling: pre-empt the setup wizard, sweep transient dialogs, screenshot what was dismissed | Y | P | N | N | N | N | N | N | N | N | still needed: errors stay dialogs in 11 |-| Drive KiCad on another computer, by name, from a cloud agent | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Runs with no desktop KiCad (shared KiCad service, kicad-cli in your container, or KiCad on another box) | Y | N | N | Y | N | P | Y | N | N | Y | `kicad-cli api-server` lets every tool run the API headless |--### Design work--| Feature | Adom | MX | KN | HP | LA | SD | PRO | AIA | KP | POA | With KiCad 11 |-|---|---|---|---|---|---|---|---|---|---|---|---|-| Schematic read (netlist, connections, lint) | Y | Y | Y | Y | Y | Y | Y | Y | P | Y | schematic API for everyone |-| Schematic edit in place (symbols, wires, labels) | Y (4) | Y | Y | N | N | P | Y | Y | Y (on 11 nightly) | Y | live, native edits replace file rewrites for everyone |-| Board read (pads, nets, tracks, routing state) | Y | Y | Y | Y | P | Y | Y | Y | Y | Y | unchanged |-| Board edit through KiCad's IPC API as native undo steps (ours: placement with courtyard checks, routing, pours and zone plans, silkscreen) | Y | P | Y | N | N | N | P | Y | Y | N | unchanged |-| Autorouting: AI routing through the API, and Freerouting installed on demand | Y | Y | Y | N | N | N | Y | P | N | Y | unchanged |-| ERC and DRC with parsed results | Y | Y | Y | N | P | Y | Y | Y | N | Y | still no live DRC over IPC |-| Every Konnect v0.13.0 capability: schematic hierarchy and sheet pins, annotation, buses, batch edits, board outline and layer setup, netclasses and rules, flip and align, update PCB from schematic, DXF, GenCAD, IPC-2581, ODB++ and IPC-D-356, footprint and symbol authoring ([the row-by-row map](konnect-gap.md)) | Y (1.0.61) | P | Y | N | N | N | P | P | P | P | unchanged |-| BOM and netlist export | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | unchanged |-| Gerber, drill, STEP, SVG, PDF export | Y | Y | Y | P | N | P | Y | P | Y | Y | export jobs over IPC for everyone |-| 3D image of a board or chip | Y | P | N | N | Y | N | Y | Y | Y | Y | `RunBoardJobExportRender` for everyone |-| 3D model diagnostics (missing, unresolved, wrong scale, hidden) | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Current density and DC voltage drop on the real copper, per net, through the vias | Y (9) | N | N | P | N | N | P | N | N | N | unchanged |-| Thermal: heat flow per layer through vias and heatsinks, temperature rise over the board | Y (9) | N | N | P | N | N | P | N | N | N | unchanged |-| Signal integrity: impedance, differential-pair skew, length matching, stackup synthesis | P (9) | N | N | N | N | N | Y | N | N | N | unchanged |-| Power integrity: decoupling and PDN checks | N | N | N | P | N | N | Y | N | N | N | unchanged |-| EMC and ESD rule checks (return paths, plane splits, stitching, protection) | N | N | N | Y | N | N | Y | N | N | N | unchanged |-| DFM against a fab's rules | Y (10) | N | N | N | N | N | Y | N | N | N | unchanged |-| SPICE simulation with results back to the AI | Y (5) | N | N | Y | N | N | Y | N | N | N | unchanged |--### Libraries and parts--| Feature | Adom | MX | KN | HP | LA | SD | PRO | AIA | KP | POA | With KiCad 11 |-|---|---|---|---|---|---|---|---|---|---|---|---|-| An open, AI-first home for components: one git-backed page per part with symbol, footprint, 3D model and a canonical cross-tool record | Y (13) | N | N | N | N | P | N | N | N | N | unchanged |-| Install symbols, footprints and 3D models, edit lib tables, heal, rescan, uninstall | Y | Y | P | N | N | P | Y | Y | N | Y | library-table commands and `ReloadLibrary` for everyone |-| PCM plugin and library install without the GUI | Y | N | N | N | N | N | N | N | N | N | unchanged: no PCM API in 11 |-| Your wiki project is also a KiCad PCM repository | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Cloud Libraries: collect parts on the wiki, install them into KiCad (or Fusion, Altium) in one step | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Parts search (Mouser, DigiKey, JLCPCB, best-source pick) | Y | Y | Y | Y | N | P | Y | N | N | N | unchanged |-| Fetch manufacturer CAD (STEP, symbol, footprint, datasheet) from the makers' own sites, through a signed-in background browser | Y (11) | N | N | N | N | P | N | N | N | N | unchanged |-| Datasheet to symbol and footprint as a dedicated tool | Y (12) | N | N | N | N | N | P | N | N | N | unchanged |-| Symbols that show the chip: the part's real 3D package outline drawn into the symbol body as native KiCad lines, and pins sorted into captioned groups by function (Power, Ground, I2C, Control) from the datasheet | Y (8) | N | N | N | N | N | N | N | N | N | unchanged |-| Cross-tool library conversion into KiCad (Altium, Fusion and others) | Y | N | N | N | N | N | N | N | N | N | unchanged |--### Around the tool--| Feature | Adom | MX | KN | HP | LA | SD | PRO | AIA | KP | POA | With KiCad 11 |-|---|---|---|---|---|---|---|---|---|---|---|---|-| Works from any AI without a per-AI install (Claude, Codex, Antigravity, Grok, Kimi) | Y | N | P | P | N | N | N | N | N | N | unchanged |-| Grok Bot and its remote computer, driven from the same workspace | Y | N | N | N | N | N | N | N | N | N | unchanged |-| One bridge ecosystem: KiCad, Fusion, Altium, browsers, Blender, SolidWorks share one host's verbs | Y | N | N | N | N | N | N | N | N | N | unchanged |-| Permission model with human-only grants and a reason on every call | Y | N | N | n/a | N | N | Y | N | Y | P | unchanged |-| Caller identity and audit on every verb | Y | P | Y | N | N | N | P | N | N | N | unchanged | | Auto-update with an early-access tier | Y | N | N | N | N | N | P | N | N | N | unchanged | | Supported uninstall | Y | N | Y | P | N | N | N | N | N | N | unchanged | | Supply-chain hygiene (signed binaries or OpenSSF badge, SBOM, checksums) | P (signed exe) | N | P | N | N | N | Y | N | N | N | unchanged |-| Runs on macOS and Linux | P (6) | Y | Y | Y | Y | Y | Y | P | P | Y | unchanged |+| Runs on macOS and Linux | N (6) | Y | Y | Y | Y | Y | Y | P | P | Y | unchanged | | Backed by a funded organization | Y (NSF) | N | P (commercial license) | N | N | Y (Seeed) | N | N | N | N | unchanged | | Contributions handled by paid staff, with measured turnaround | Y (7) | P (one maintainer, many contributors) | P | P | N (idle) | P | P | P | P | P | unchanged | -(1) What we do: boards and the 3D viewer open behind the user's window, menus are posted to a single window, the 3D view is driven with no focus, and the taskbar badge shows when an AI is driving. What we cannot do on one machine: KiCad 10.0.5's Symbol Editor comes to the front and takes the keyboard, and the Footprint Editor takes keyboard focus, whatever the caller asks for ([measured, with video](kicad-wishlist.md)). Pushing the window back afterwards interrupted users more than it helped, so that guard is off by default since 1.0.50. The complete answer today is to run KiCad on a second computer and drive it through the mesh, so nothing ever lands on your screen.--(2) Opt-in GUI driver that copies the frame's screen rectangle, so the window has to be visible.--(3) MCP progress milestones on exports, not measured progress.--(4) Our schematic verbs write the file format and run kicad-cli ERC after every write, then reload the editor. KiPilot already edits live through the KiCad 11 schematic API; we move to it as 11 stabilises. (5) [Adom SPICE](https://wiki.adom.inc/adom/adom-spice-skillpack) runs one study in ngspice (in the container), LTspice or PSpice for TI (on a Windows PC through Adom Bridge), or all three on one set of axes, with an agreement table that names the engine out of line, parallel Monte Carlo, and a ledger of every run's time and AI tokens. PSpice for TI is the only way to run the encrypted PSpice models many power-device vendors ship, and no other tool here drives it. The AI writes the study (netlists, signals, measurements). kicad-happy goes further on the other end, generating testbenches automatically from subcircuits it recognises in a schematic, and kicad-mcp-pro runs operating point, AC, transient and DC sweep analyses in ngspice straight from the KiCad project. -(6) Linux: supported, Wayland and X11, with window state, screenshots, menus and dialogs ([KiCad on Linux](linux.md)). macOS: a platform layer exists but needs its owner's build and test before we mark it supported.+(6) The bridge has macOS and Linux platform layers, but each needs an owner's build and test before we mark it supported. (7) The KiCad Bridge page has had 118 issues since June; 105 are closed, half of them within 16.5 hours, and issues from people other than the project lead closed in a median of 12.2 hours. Three developers on staff review pull requests across the Adom bridges. All 20 pull requests so far came from our own contributors and merged in a median of 8 minutes; we have not yet had one from outside, and we would like to. -(9) [Adom Fields](https://wiki.adom.inc/adom/adom-fields) solves the physics numerically on the board's own filled copper: a DC conduction solve per loaded net between its terminals and through every via barrel (amps per square millimetre on every cell, the peak and where it is), and a heat-flow solve over four coupled copper layers with the dielectric, the vias, conducting heatsink blocks and still or moving air (temperature rise over the board, each hot part's heat split between its layer, its vias and the dielectric). It paints the result on the board in 3D with each part's own model standing in its hot spot, and lists the issues with the fix (a neck over 60 A/mm², a tab over its rise budget, a chip short of thermal vias). Its signals view (0.4) estimates microstrip impedance, differential-pair skew, reference-plane coverage and overlap capacitance. Its limits are on its page: DC only, a flat convection coefficient, no enclosure or radiation. kicad-mcp-pro covers more disciplines with closed-form estimates, which its README rates at typically 5 to 10 percent, "not a substitute for a 2D/3D field solver"; the two approaches complement each other.--(10) [PCB design rules by fab](https://wiki.adom.inc/adom/pcb-design-rules) holds the DRC limits of popular fabs per process and layer count, generated for KiCad, Fusion and Altium; the bridge applies them to the board (`kicad_set_design_rules`) and KiCad's own DRC does the check. kicad-mcp-pro adds a manufacturing cost estimate, which we do not have.--(11) [pup](https://wiki.adom.inc/adom/pup-bridge) gives the AI a real Chrome on the desktop, in the background and signed in as you, so it can work the vendor sites that block automation or need a login. [adom-chip-fetcher](https://wiki.adom.inc/adom/adom-chip-fetcher) uses it to pull a part's rich STEP model, KiCad symbol and footprint, datasheet and design-help PDFs from the manufacturer's own site first, falling back to distributors or Component Search Engine only with the user's approval. Seeed's server downloads symbol, footprint and 3D from its own free parts registry.--(12) [adom-ds2sf](https://wiki.adom.inc/adom/adom-ds2sf) turns a datasheet into a symbol and footprint with provenance. It matters less than it did: frontier models such as Claude Opus 5.5 and GPT-6 Astra now read a datasheet and draw a correct symbol well on their own, which helps every tool on this page equally. ds2sf remains useful for its structured output and provenance.--(13) The [Adom wiki](https://wiki.adom.inc) hosts 22,191 public component pages today (22,580 in all). Each page is a git repository with anonymous file URLs, so an AI can read, clone, install or improve a part like code: the canonical adom-lbr record (symbol, footprint, 3D reference and pin-to-pad map in one document), the KiCad `.kicad_sym` and `.kicad_mod`, a Fusion and EAGLE `.lbr`, the manufacturer STEP and web 3D models, with symbol, footprint and 3D viewers on the page itself. Anyone can file an issue or send a pull request on a part, a part one user adds is there for every user's AI from then on, and any page doubles as a KiCad PCM repository. [adom-symbol](https://wiki.adom.inc/adom/adom-symbol), [adom-footprint](https://wiki.adom.inc/adom/adom-footprint) and [adom-lbr](https://wiki.adom.inc/adom/adom-lbr) build and convert what lives there. Seeed's free PartReel registry (more than 21,000 parts) is the nearest counterpart.--(8) [adom-symbol](https://wiki.adom.inc/adom/adom-symbol) bakes the chip's outline (rendered from the manufacturer's STEP by chip-thumbnailer, with the part number lasered on) into the middle of the symbol as real polylines, fitted between the pin-name columns so it never covers a pin. Pins come grouped by function from the datasheet extraction, with bold group captions, in two-column, square or square-grouped layouts. A schematic then reads like the board: you can see which package each part is. adom-lbr carries the same drawing and captions into native Altium and Fusion libraries, so the part looks the same in all three tools. Any wiki component page can offer every layout variant, generated in the browser, as a `.kicad_sym` download.--## What KiCad 11 changes--We read the KiCad 11 nightlies (the `master` API definitions, 2026-10-04) so the comparison does not age badly. KiCad 11 closes many gaps that every tool on this page works around:--- `kicad-cli api-server` runs the IPC API headless, with no windows at all. Any tool can then do board and schematic work without a desktop KiCad.-- `OpenDocument`, `CloseDocument`, `CloseAllDocuments`, `SaveDocument` and `GetDocumentModifiedState` (open and close are headless-only for now).-- `OpenLibraryItem` opens the Symbol or Footprint Editor at a library item.-- Library tables: read, add, update, delete, `ReloadLibrary`, `LoadAllLibraries`, library status.-- `RefillZones` by zone id, synchronous in headless sessions.-- `RunBoardJobExportRender` and export jobs (Gerber, drill, ODB++, IPC-2581, PDF, SVG and more) over IPC.-- The schematic API (KiCad issue #2077), which KiPilot already drives. KiCad lead developer Jon Evans wrote on [the forum](https://forum.kicad.info/t/66611/37) on 2026-09-19 that it will be marked stable only at the final KiCad 11 release, so expect breaking changes until then.--What KiCad 11 does not change, and where the bridge's desktop work keeps mattering:--- The Symbol and Footprint Editors still take focus when they open.-- Errors still appear only as dialogs; there is no error, log or event channel in the API.-- `RunAction` exists but is explicitly not a stable API, and is registered only in the board editor.-- No control of the open 3D viewer, no PCM API, no live DRC over IPC, and one API server per machine on Windows.--We have asked the KiCad developers for the second list, with measurements and videos, in [our KiCad wishlist](kicad-wishlist.md).--## Where we lead, with the evidence--- **Real boards, on camera, with the numbers.** The [ESC G431 demo](esc-demo.md) is a public 149-part, 4-layer drone ESC placed and routed from scratch through the bridge by Claude Fable 5.1, GPT-6 Astra and Freerouting, with wall clock, tokens, dollars, result quality and the caveats (an electrical review found power-path and thermal issues in one layout despite clean DRC). The [adom/codex page](https://wiki.adom.inc/adom/codex) has GPT-6 Astra routing a public board through the bridge, recorded.-- **A home for every component.** The Adom wiki is an AI-first repository for parts: 22,191 public component pages, each a git repo with the symbol, footprint, 3D model and a canonical cross-tool record, open to issues and pull requests like code. Every tool on this page, not just ours, could pull parts from it.-- **Current and heat, solved on the copper.** Adom Fields is the only tool here that solves current density and heat flow numerically on the board's real copper and vias and shows them on the board in 3D, with the fix for each issue.-- **The desktop around KiCad.** Background screenshots of covered windows with their dialogs, native video recording of a window or the desktop, the taskbar badge and progress bar, and posted menu commands. Recorded examples are on the [wishlist page](kicad-wishlist.md).-- **A second computer.** Adom Bridge's mesh lets an AI in any container drive KiCad on a named machine, so the user's own screen is never touched.-- **Install to uninstall.** Silent KiCad install with verification, a signed single binary updated from a sha-verified manifest with an early-access tier, and a supported uninstall.-- **Libraries end to end.** Install, heal, rescan and uninstall; PCM installs that match KiCad's own layout; any wiki page as a PCM repository; Cloud Libraries from the wiki into KiCad in one step.-- **Breadth of verbs, all tested.** 119 verbs, run as a full battery before releases (119 passed, 0 failed on one gate machine for 1.0.52). ## Where others lead -- **Cross-platform.** Every other tool runs on macOS and Linux. Our Linux build (KiCad from the distro, the PPA, snap or Flatpak) runs on Wayland and X11 desktops with window state, screenshots, menus and dialogs ([KiCad on Linux](linux.md)); the macOS build is a separate port.+- **Cross-platform.** Every other tool runs on macOS and Linux. Our Linux build (KiCad from the distro, snap or Flatpak) is on the insiders tier since 1.0.58, without the window verbs yet; the macOS build is a separate port. - **Breadth of design checks.** kicad-mcp-pro covers signal integrity, power integrity, EMC and DFM cost with fast estimates, and kicad-happy audits power trees, ESD and EMC and generates SPICE testbenches automatically. We have no PDN, EMC or ESD checks yet. - **Supply-chain hygiene.** kicad-mcp-pro's OpenSSF Silver, SBOMs and checksums set the bar; we sign our executable but publish no SBOM yet. - **Community size.** KiCAD-MCP-Server has thousands of stars and many outside contributors. We live on our own wiki rather than GitHub, which makes us harder to find, and we have no outside pull requests yet.-- **KiCad 11 schematic API.** KiPilot is there first.-- **Chat inside KiCad.** KiCad-AI-Assistant is the only one.--## Where we chose a different approach--Respectfully, three places where we looked at what others built and went another way:--- **Reading KiCad files without KiCad.** kicad-happy's parsers, validated against tens of thousands of files, are an impressive piece of work. We chose to let KiCad read its own files, through a shared KiCad service, kicad-cli in the user's container, or KiCad on another machine, so format changes in new KiCad versions reach us for free. We parse files ourselves only for checks KiCad does not offer.-- **Permission profiles inside the tool.** kicad-mcp-pro's profiles are a careful design. We put that control in two other layers instead: the AI harness (Claude Code's and Codex's approval modes) and Adom Bridge, which gates risky desktop actions (shell, synthetic input, file writes, process control) behind human-only, timed grants with a reason on every call.-- **Toolsets and context budgets.** Konnect's starter kit and on-demand toolsets are a thoughtful answer to large tool lists. We went with per-verb self-description and skills the AI discovers by trigger. Either works; both are better than dumping hundreds of tools into the context.--## The ecosystem around the bridge--Most tools on this page are a KiCad tool. This bridge is one part of an environment built for hardware work, and the AI uses the parts together.--| Companion | What it adds |-|---|---|-| [Hydrogen](https://wiki.adom.inc/adom/hydrogen) | One workspace where Claude, Codex, Antigravity, Grok and Kimi drive KiCad through the same bridge, with no per-AI MCP setup. |-| [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) | The desktop host for every bridge: KiCad, Fusion, Altium, browsers, Blender, SolidWorks. Screenshots, window control, native video recording, taskbar status, files, permissions, identity, and the mesh that reaches other machines by name. |-| [Grok Bridge](https://wiki.adom.inc/adom/grok-bridge) | Grok Bot and its remote computer, driven from Hydrogen next to the other AIs. |-| [Adom Fields](https://wiki.adom.inc/adom/adom-fields) | Current density and heat flow solved on the board's copper and vias, temperature over the board, signal estimates, painted on the board in 3D with the fix for each issue. |-| [Adom SPICE](https://wiki.adom.inc/adom/adom-spice-skillpack) | ngspice, LTspice and PSpice for TI from one dashboard, with cross-engine agreement, Monte Carlo and a run ledger. |-| [service-kicad](https://wiki.adom.inc/adom/service-kicad) | Headless KiCad as a shared service or kicad-cli in your container: DRC, ERC, Gerbers, STEP, GLB, renders, library lookups. |-| [adom-parts-search](https://wiki.adom.inc/adom/adom-parts-search) | Mouser, DigiKey and JLCPCB in one search with a best-source pick. |-| Cloud Libraries | Add parts on the wiki to your own cloud library, check KiCad, Fusion and Altium availability, and install the bundle into KiCad in one step. |-| [The parts wiki](https://wiki.adom.inc) | 22,191 public component pages, each a git repo with symbol, footprint, 3D model, Fusion and EAGLE library and the canonical adom-lbr record, ready for the AI. A part added by one user is there for every user after. Built and kept running by Adom with funding in part from the National Science Foundation. |-| [pup](https://wiki.adom.inc/adom/pup-bridge) and [adom-chip-fetcher](https://wiki.adom.inc/adom/adom-chip-fetcher) | A real, signed-in browser the AI drives in the background, used to gather parts data and download manufacturer STEP models, symbols, footprints and datasheets from the makers' own sites. |-| [adom-lbr](https://wiki.adom.inc/adom/adom-lbr) and [adom-ds2sf](https://wiki.adom.inc/adom/adom-ds2sf) | Library conversion across CAD formats, and datasheet to symbol and footprint with provenance. |-| [adom-symbol](https://wiki.adom.inc/adom/adom-symbol), [adom-footprint](https://wiki.adom.inc/adom/adom-footprint), [adom-schematic](https://wiki.adom.inc/adom/adom-schematic), [adom-2dboard](https://wiki.adom.inc/adom/adom-2dboard) | Web views of every KiCad surface, so the AI and the user can look at a part or board without opening KiCad. |-| Wiki PCM repositories | Every wiki page is git with anonymous file URLs, so a project on the wiki is also a KiCad PCM repository. See [Your wiki page as a KiCad PCM repository](pcm.md). |-| People and funding | Adom, including the wiki that hosts the parts, is funded in part by the National Science Foundation to build and maintain this for the long term, and three developers on staff review contributions across the bridges. CERN has backed KiCad itself since 2014; we would like the AI layer on top of it to have the same kind of staying power. |--## Simulation--[Adom SPICE](https://wiki.adom.inc/adom/adom-spice-skillpack) is a dashboard and skill pack built from a hands-on comparison on a real board (a TPS54202 12 V to 5 V buck). It runs a study in ngspice in the container, and drives LTspice and PSpice for TI on a Windows PC through Adom Bridge (PSpice for TI has no batch mode, so the dashboard opens it, presses Run and reads the results back). Every engine is measured by the same code, so when two disagree the dashboard names the measurement and the engine. On the example board all three engines agree on all eight measurements; a 2,000-run Monte Carlo of a filter takes about 5 seconds on 16 cores. The [4-minute video](https://wiki.adom.inc/adom/adom-spice-skillpack) shows an AI in Hydrogen running the whole comparison from one prompt.--Next: a `kicad_export_spice` verb so a study can start from the KiCad schematic's own netlist (`kicad-cli sch export netlist --format spice`), and SPICE models carried on parts wiki pages next to the symbol and footprint.--## Method--Seventeen repositories were checked on 2026-10-04, along with the KiCad forum threads where these tools are discussed: README, install docs, tool lists, release notes and assets, open issues. Stars, last push, releases and licenses came from the GitHub API that day. A feature is marked Y only when the tool's own documentation names it; where we could not verify, we marked the lower value and invite the author to correct us. Marks for the Adom KiCad Bridge come from the native catalog (119 verbs in 1.0.52 and later), the verb battery, and the measurements on the [wishlist page](kicad-wishlist.md). KiCad 11 notes come from the `master` branch API definitions on 2026-10-04. Corrections are welcome on the [issue tracker](https://wiki.adom.inc/adom/kicad-bridge/issues).+-- /dev/null
rust/Cargo.lock+80−1865@@ -1,1964 +1,179 @@-# This file is automatically @generated by Cargo.-# It is not intended for manual editing.-version = 4--[[package]]-name = "adler2"-version = "2.0.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"--[[package]]-name = "adom-eda-checks"-version = "0.24.0"-dependencies = [- "roxmltree",- "serde_json",- "svgtypes",-]--[[package]]-name = "android_system_properties"-version = "0.1.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"-dependencies = [- "libc",-]--[[package]]-name = "anyhow"-version = "1.0.104"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"--[[package]]-name = "arrayref"-version = "0.3.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"--[[package]]-name = "arrayvec"-version = "0.7.8"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"--[[package]]-name = "ascii"-version = "1.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"--[[package]]-name = "async-broadcast"-version = "0.7.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"-dependencies = [- "event-listener",- "event-listener-strategy",- "futures-core",- "pin-project-lite",-]--[[package]]-name = "async-channel"-version = "2.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"-dependencies = [- "concurrent-queue",- "event-listener-strategy",- "futures-core",- "pin-project-lite",-]--[[package]]-name = "async-executor"-version = "1.14.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"-dependencies = [- "async-task",- "concurrent-queue",- "fastrand",- "futures-lite",- "pin-project-lite",- "slab",-]--[[package]]-name = "async-io"-version = "2.6.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"-dependencies = [- "autocfg",- "cfg-if",- "concurrent-queue",- "futures-io",- "futures-lite",- "parking",- "polling",- "rustix",- "slab",- "windows-sys 0.61.2",-]--[[package]]-name = "async-lock"-version = "3.4.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"-dependencies = [- "event-listener",- "event-listener-strategy",- "pin-project-lite",-]--[[package]]-name = "async-process"-version = "2.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"-dependencies = [- "async-channel",- "async-io",- "async-lock",- "async-signal",- "async-task",- "blocking",- "cfg-if",- "event-listener",- "futures-lite",- "rustix",-]--[[package]]-name = "async-recursion"-version = "1.2.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "async-signal"-version = "0.2.14"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"-dependencies = [- "async-io",- "async-lock",- "atomic-waker",- "cfg-if",- "futures-core",- "futures-io",- "rustix",- "signal-hook-registry",- "slab",- "windows-sys 0.61.2",-]--[[package]]-name = "async-task"-version = "4.7.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"--[[package]]-name = "async-trait"-version = "0.1.92"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "atomic-waker"-version = "1.1.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"--[[package]]-name = "autocfg"-version = "1.5.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"--[[package]]-name = "base64"-version = "0.22.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"--[[package]]-name = "base64"-version = "0.23.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"--[[package]]-name = "bitflags"-version = "1.3.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"--[[package]]-name = "bitflags"-version = "2.13.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"--[[package]]-name = "blocking"-version = "1.7.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"-dependencies = [- "async-channel",- "async-task",- "futures-io",- "futures-lite",- "piper",-]--[[package]]-name = "bumpalo"-version = "3.20.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"--[[package]]-name = "bytemuck"-version = "1.25.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"--[[package]]-name = "bytes"-version = "1.12.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"--[[package]]-name = "cc"-version = "1.4.5"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"-dependencies = [- "find-msvc-tools",- "shlex",-]--[[package]]-name = "cfg-if"-version = "1.0.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"--[[package]]-name = "chrono"-version = "0.4.45"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"-dependencies = [- "iana-time-zone",- "js-sys",- "num-traits",- "wasm-bindgen",- "windows-link 0.2.1",-]--[[package]]-name = "chunked_transfer"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"--[[package]]-name = "cmake"-version = "0.1.58"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"-dependencies = [- "cc",-]--[[package]]-name = "concurrent-queue"-version = "2.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"-dependencies = [- "crossbeam-utils",-]--[[package]]-name = "core-foundation-sys"-version = "0.8.7"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"--[[package]]-name = "crc32fast"-version = "1.5.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"-dependencies = [- "cfg-if",-]--[[package]]-name = "crossbeam-utils"-version = "0.8.23"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"--[[package]]-name = "data-url"-version = "0.3.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"--[[package]]-name = "either"-version = "1.18.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"--[[package]]-name = "endi"-version = "1.1.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"--[[package]]-name = "enumflags2"-version = "0.7.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"-dependencies = [- "enumflags2_derive",- "serde",-]--[[package]]-name = "enumflags2_derive"-version = "0.7.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"-dependencies = [- "proc-macro2",- "quote",- "syn 2.0.119",-]--[[package]]-name = "equivalent"-version = "1.0.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"--[[package]]-name = "errno"-version = "0.3.14"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"-dependencies = [- "libc",- "windows-sys 0.59.0",-]--[[package]]-name = "euclid"-version = "0.22.14"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"-dependencies = [- "num-traits",-]--[[package]]-name = "event-listener"-version = "5.4.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"-dependencies = [- "parking",- "pin-project-lite",-]--[[package]]-name = "event-listener-strategy"-version = "0.5.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"-dependencies = [- "event-listener",- "pin-project-lite",-]--[[package]]-name = "fastrand"-version = "2.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"--[[package]]-name = "fdeflate"-version = "0.3.7"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"-dependencies = [- "simd-adler32",-]--[[package]]-name = "find-msvc-tools"-version = "0.1.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"--[[package]]-name = "flate2"-version = "1.1.10"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"-dependencies = [- "crc32fast",- "miniz_oxide 0.9.1",- "zlib-rs",-]--[[package]]-name = "float-cmp"-version = "0.9.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"--[[package]]-name = "futures-core"-version = "0.3.34"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"--[[package]]-name = "futures-io"-version = "0.3.34"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"--[[package]]-name = "futures-lite"-version = "2.6.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"-dependencies = [- "fastrand",- "futures-core",- "futures-io",- "parking",- "pin-project-lite",-]--[[package]]-name = "futures-task"-version = "0.3.34"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"--[[package]]-name = "futures-util"-version = "0.3.34"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"-dependencies = [- "futures-core",- "futures-task",- "pin-project-lite",- "slab",-]--[[package]]-name = "gethostname"-version = "1.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"-dependencies = [- "rustix",- "windows-link 0.2.1",-]--[[package]]-name = "getrandom"-version = "0.2.17"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"-dependencies = [- "cfg-if",- "libc",- "wasi",-]--[[package]]-name = "getrandom"-version = "0.4.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"-dependencies = [- "cfg-if",- "libc",- "r-efi",-]--[[package]]-name = "hashbrown"-version = "0.17.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"--[[package]]-name = "hermit-abi"-version = "0.5.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"--[[package]]-name = "hex"-version = "0.4.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"--[[package]]-name = "http"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"-dependencies = [- "bytes",- "itoa",-]--[[package]]-name = "httparse"-version = "1.10.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"--[[package]]-name = "httpdate"-version = "1.0.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"--[[package]]-name = "iana-time-zone"-version = "0.1.65"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"-dependencies = [- "android_system_properties",- "core-foundation-sys",- "iana-time-zone-haiku",- "js-sys",- "log",- "wasm-bindgen",- "windows-core 0.62.2",-]--[[package]]-name = "iana-time-zone-haiku"-version = "0.1.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"-dependencies = [- "cc",-]--[[package]]-name = "imagesize"-version = "0.13.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"--[[package]]-name = "indexmap"-version = "2.14.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"-dependencies = [- "equivalent",- "hashbrown",-]--[[package]]-name = "itertools"-version = "0.14.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"-dependencies = [- "either",-]--[[package]]-name = "itoa"-version = "1.0.18"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"--[[package]]-name = "js-sys"-version = "0.3.105"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"-dependencies = [- "cfg-if",- "futures-util",- "wasm-bindgen",-]--[[package]]-name = "kicad-bridge"-version = "1.0.98"-dependencies = [- "adom-eda-checks",- "kicad-core",- "kicad-platform",- "serde",- "serde_json",- "tiny_http",-]--[[package]]-name = "kicad-core"-version = "1.0.98"-dependencies = [- "kicad-ipc-rs",- "kicad-platform",- "prost-types",- "quick-xml",- "resvg",- "serde",- "serde_json",- "ureq",-]--[[package]]-name = "kicad-ipc-rs"-version = "0.5.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "17270cabeef7d717f31b94caa7540b59a99b4a0935c36ca3645f319594f7a3ff"-dependencies = [- "nng",- "prost",- "prost-types",- "thiserror",- "tokio",-]--[[package]]-name = "kicad-platform"-version = "1.0.98"-dependencies = [- "serde",- "serde_json",- "uiautomation",- "windows 0.61.3",- "winreg",- "x11rb",- "zbus",-]--[[package]]-name = "kurbo"-version = "0.11.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"-dependencies = [- "arrayvec",- "euclid",- "smallvec",-]--[[package]]-name = "libc"-version = "0.2.189"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"--[[package]]-name = "linux-raw-sys"-version = "0.12.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"--[[package]]-name = "log"-version = "0.4.34"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"--[[package]]-name = "memchr"-version = "2.8.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"--[[package]]-name = "memoffset"-version = "0.9.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"-dependencies = [- "autocfg",-]--[[package]]-name = "miniz_oxide"-version = "0.8.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"-dependencies = [- "adler2",- "simd-adler32",-]--[[package]]-name = "miniz_oxide"-version = "0.9.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"-dependencies = [- "adler2",- "simd-adler32",-]--[[package]]-name = "nng"-version = "1.0.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "20a18ed7ec1c8ff1658bcfea2ce76dcedf8be21dd9e8dd2e9762ba9632d16203"-dependencies = [- "log",- "nng-sys",-]--[[package]]-name = "nng-sys"-version = "1.4.0-rc.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "50d0e7f39a8fd1792a710da9351320e864dc799cf439960e332c977b2f876da6"-dependencies = [- "cmake",- "version_check",-]--[[package]]-name = "num-traits"-version = "0.2.19"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"-dependencies = [- "autocfg",-]--[[package]]-name = "once_cell"-version = "1.21.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"--[[package]]-name = "ordered-stream"-version = "0.2.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"-dependencies = [- "futures-core",- "pin-project-lite",-]--[[package]]-name = "parking"-version = "2.2.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"--[[package]]-name = "percent-encoding"-version = "2.3.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"--[[package]]-name = "pico-args"-version = "0.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"--[[package]]-name = "pin-project-lite"-version = "0.2.17"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"--[[package]]-name = "piper"-version = "0.2.5"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"-dependencies = [- "atomic-waker",- "fastrand",- "futures-io",-]--[[package]]-name = "png"-version = "0.17.16"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"-dependencies = [- "bitflags 1.3.2",- "crc32fast",- "fdeflate",- "flate2",- "miniz_oxide 0.8.9",-]--[[package]]-name = "polling"-version = "3.11.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"-dependencies = [- "cfg-if",- "concurrent-queue",- "hermit-abi",- "pin-project-lite",- "rustix",- "windows-sys 0.61.2",-]--[[package]]-name = "proc-macro-crate"-version = "3.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"-dependencies = [- "toml_edit",-]--[[package]]-name = "proc-macro2"-version = "1.0.107"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"-dependencies = [- "unicode-ident",-]--[[package]]-name = "prost"-version = "0.14.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"-dependencies = [- "bytes",- "prost-derive",-]--[[package]]-name = "prost-derive"-version = "0.14.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"-dependencies = [- "anyhow",- "itertools",- "proc-macro2",- "quote",- "syn 2.0.119",-]--[[package]]-name = "prost-types"-version = "0.14.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"-dependencies = [- "prost",-]--[[package]]-name = "quick-xml"-version = "0.37.5"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"-dependencies = [- "memchr",-]--[[package]]-name = "quote"-version = "1.0.47"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"-dependencies = [- "proc-macro2",-]--[[package]]-name = "r-efi"-version = "6.0.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"--[[package]]-name = "resvg"-version = "0.45.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"-dependencies = [- "log",- "pico-args",- "rgb",- "svgtypes",- "tiny-skia",- "usvg",-]--[[package]]-name = "rgb"-version = "0.8.53"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"-dependencies = [- "bytemuck",-]--[[package]]-name = "ring"-version = "0.17.14"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"-dependencies = [- "cc",- "cfg-if",- "getrandom 0.2.17",- "libc",- "untrusted",- "windows-sys 0.52.0",-]--[[package]]-name = "roxmltree"-version = "0.20.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"--[[package]]-name = "rustix"-version = "1.1.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"-dependencies = [- "bitflags 2.13.2",- "errno",- "libc",- "linux-raw-sys",- "windows-sys 0.59.0",-]--[[package]]-name = "rustls"-version = "0.23.44"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"-dependencies = [- "log",- "once_cell",- "ring",- "rustls-pki-types",- "rustls-webpki",- "subtle",- "zeroize",-]--[[package]]-name = "rustls-pki-types"-version = "1.15.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"-dependencies = [- "zeroize",-]--[[package]]-name = "rustls-webpki"-version = "0.103.15"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"-dependencies = [- "ring",- "rustls-pki-types",- "untrusted",-]--[[package]]-name = "rustversion"-version = "1.0.23"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"--[[package]]-name = "serde"-version = "1.0.229"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"-dependencies = [- "serde_core",- "serde_derive",-]--[[package]]-name = "serde_core"-version = "1.0.229"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"-dependencies = [- "serde_derive",-]--[[package]]-name = "serde_derive"-version = "1.0.229"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "serde_json"-version = "1.0.151"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"-dependencies = [- "itoa",- "memchr",- "serde",- "serde_core",- "zmij",-]--[[package]]-name = "serde_repr"-version = "0.1.21"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "shlex"-version = "2.0.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"--[[package]]-name = "signal-hook-registry"-version = "1.4.8"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"-dependencies = [- "errno",- "libc",-]--[[package]]-name = "simd-adler32"-version = "0.3.10"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"--[[package]]-name = "simplecss"-version = "0.2.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"-dependencies = [- "log",-]--[[package]]-name = "siphasher"-version = "1.0.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"--[[package]]-name = "slab"-version = "0.4.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"--[[package]]-name = "smallvec"-version = "1.16.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"--[[package]]-name = "strict-num"-version = "0.1.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"-dependencies = [- "float-cmp",-]--[[package]]-name = "subtle"-version = "2.6.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"--[[package]]-name = "svgtypes"-version = "0.15.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"-dependencies = [- "kurbo",- "siphasher",-]--[[package]]-name = "syn"-version = "2.0.119"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"-dependencies = [- "proc-macro2",- "quote",- "unicode-ident",-]--[[package]]-name = "syn"-version = "3.0.5"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"-dependencies = [- "proc-macro2",- "quote",- "unicode-ident",-]--[[package]]-name = "tempfile"-version = "3.27.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"-dependencies = [- "fastrand",- "getrandom 0.4.3",- "once_cell",- "rustix",- "windows-sys 0.59.0",-]--[[package]]-name = "thiserror"-version = "2.0.20"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"-dependencies = [- "thiserror-impl",-]--[[package]]-name = "thiserror-impl"-version = "2.0.20"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "tiny-skia"-version = "0.11.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"-dependencies = [- "arrayref",- "arrayvec",- "bytemuck",- "cfg-if",- "log",- "png",- "tiny-skia-path",-]--[[package]]-name = "tiny-skia-path"-version = "0.11.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"-dependencies = [- "arrayref",- "bytemuck",- "strict-num",-]--[[package]]-name = "tiny_http"-version = "0.12.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"-dependencies = [- "ascii",- "chunked_transfer",- "httpdate",- "log",-]--[[package]]-name = "tokio"-version = "1.53.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"-dependencies = [- "pin-project-lite",- "tokio-macros",-]--[[package]]-name = "tokio-macros"-version = "2.7.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "toml_datetime"-version = "1.1.2+spec-1.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"-dependencies = [- "serde_core",-]--[[package]]-name = "toml_edit"-version = "0.25.17+spec-1.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"-dependencies = [- "indexmap",- "toml_datetime",- "toml_parser",- "winnow",-]--[[package]]-name = "toml_parser"-version = "1.1.5+spec-1.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"-dependencies = [- "winnow",-]--[[package]]-name = "tracing"-version = "0.1.44"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"-dependencies = [- "pin-project-lite",- "tracing-attributes",- "tracing-core",-]--[[package]]-name = "tracing-attributes"-version = "0.1.31"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"-dependencies = [- "proc-macro2",- "quote",- "syn 2.0.119",-]--[[package]]-name = "tracing-core"-version = "0.1.36"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"-dependencies = [- "once_cell",-]--[[package]]-name = "uds_windows"-version = "1.2.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"-dependencies = [- "memoffset",- "tempfile",- "windows-sys 0.61.2",-]--[[package]]-name = "uiautomation"-version = "0.24.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ffe4eddc6ccbe9fc8ac98dffc2ceff8ca475843283a090979bbafe2b832b7b19"-dependencies = [- "chrono",- "uiautomation_derive",- "windows 0.62.2",- "windows-core 0.62.2",-]--[[package]]-name = "uiautomation_derive"-version = "0.7.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1c893ec96e05c3c81ba46f838f3190549ec185ef377d582ab3d44202b2dfacbd"-dependencies = [- "proc-macro2",- "quote",- "syn 3.0.5",-]--[[package]]-name = "unicode-ident"-version = "1.0.24"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"--[[package]]-name = "untrusted"-version = "0.9.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"--[[package]]-name = "ureq"-version = "3.4.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"-dependencies = [- "base64 0.23.1",- "flate2",- "log",- "percent-encoding",- "rustls",- "rustls-pki-types",- "ureq-proto",- "utf8-zero",- "webpki-roots",-]--[[package]]-name = "ureq-proto"-version = "0.6.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"-dependencies = [- "base64 0.23.1",- "http",- "httparse",- "log",-]--[[package]]-name = "usvg"-version = "0.45.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"-dependencies = [- "base64 0.22.1",- "data-url",- "flate2",- "imagesize",- "kurbo",- "log",- "pico-args",- "roxmltree",- "simplecss",- "siphasher",- "strict-num",- "svgtypes",- "tiny-skia-path",- "xmlwriter",-]--[[package]]-name = "utf8-zero"-version = "0.8.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"--[[package]]-name = "uuid"-version = "1.29.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"-dependencies = [- "js-sys",- "serde_core",- "wasm-bindgen",-]--[[package]]-name = "version_check"-version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]]-name = "wasi"-version = "0.11.1+wasi-snapshot-preview1"+name = "autocfg"+version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]]-name = "wasm-bindgen"-version = "0.2.128"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"-dependencies = [- "cfg-if",- "once_cell",- "rustversion",- "wasm-bindgen-macro",- "wasm-bindgen-shared",+name = "bumpalo"+version = "3.20.3"+ "cc", ] [[package]]-name = "wasm-bindgen-macro"-version = "0.2.128"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"-dependencies = [- "quote",- "wasm-bindgen-macro-support",+name = "core-foundation-sys"+version = "0.8.7"+ "cfg-if", ] [[package]]-name = "wasm-bindgen-macro-support"-version = "0.2.128"+name = "data-url"+version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"-dependencies = [- "bumpalo",- "proc-macro2",- "quote",- "syn 3.0.5",- "wasm-bindgen-shared",-]+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]]-name = "wasm-bindgen-shared"-version = "0.2.128"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"-dependencies = [- "unicode-ident",+name = "euclid"+version = "0.22.14"+ "num-traits", ] [[package]]-name = "webpki-roots"-version = "1.0.9"+name = "fdeflate"+version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"-dependencies = [- "rustls-pki-types",-]+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]]-name = "windows"-version = "0.61.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"-dependencies = [- "windows-collections 0.2.0",- "windows-core 0.61.2",- "windows-future 0.2.1",- "windows-link 0.1.3",- "windows-numerics 0.2.0",+name = "futures-task"+version = "0.3.34"+ "slab", ] [[package]]-name = "windows"-version = "0.62.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"-dependencies = [- "windows-collections 0.3.2",- "windows-core 0.62.2",- "windows-future 0.3.2",- "windows-numerics 0.3.1",+name = "getrandom"+version = "0.2.17"+ "wasi", ] [[package]]-name = "windows-collections"-version = "0.2.0"+name = "http"+version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"-dependencies = [- "windows-core 0.61.2",-]+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]]-name = "windows-collections"-version = "0.3.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"-dependencies = [- "windows-core 0.62.2",+name = "itertools"+version = "0.14.0"+ "uiautomation",+ "windows 0.61.3",+ "winreg", ] [[package]]-name = "windows-core"-version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"-dependencies = [- "windows-implement",- "windows-interface",- "windows-link 0.1.3",- "windows-result 0.3.4",- "windows-strings 0.4.2",-]+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]]-name = "windows-core"-version = "0.62.2"+name = "log"+version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"-dependencies = [- "windows-implement",- "windows-interface",- "windows-link 0.2.1",- "windows-result 0.4.1",- "windows-strings 0.5.1",-]+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]]-name = "windows-future"-version = "0.2.1"+name = "miniz_oxide"+version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"-dependencies = [- "windows-core 0.61.2",- "windows-link 0.1.3",- "windows-threading 0.1.0",-]+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]]-name = "windows-future"-version = "0.3.2"+name = "percent-encoding"+version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"-dependencies = [- "windows-core 0.62.2",- "windows-link 0.2.1",- "windows-threading 0.2.1",-]+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]]-name = "windows-implement"-version = "0.60.2"+name = "png"+version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [- "proc-macro2",- "quote",- "syn 2.0.119",+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9", ] [[package]]-name = "windows-interface"-version = "0.59.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"-dependencies = [+name = "proc-macro2"+version = "1.0.107" "proc-macro2",- "quote",- "syn 2.0.119", ] [[package]]-name = "windows-link"-version = "0.1.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"--[[package]]-name = "windows-link"-version = "0.2.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"--[[package]]-name = "windows-numerics"-version = "0.2.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"+name = "resvg"+version = "0.45.1" dependencies = [- "windows-core 0.61.2",- "windows-link 0.1.3",-]--[[package]]-name = "windows-numerics"-version = "0.3.1"+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0", source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"-dependencies = [- "windows-core 0.62.2",- "windows-link 0.2.1",-]+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]]-name = "windows-result"-version = "0.3.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"-dependencies = [- "windows-link 0.1.3",+name = "rustls"+version = "0.23.44"+ "zmij", ] [[package]]-name = "windows-result"-version = "0.4.1"+name = "shlex"+version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"-dependencies = [- "windows-link 0.2.1",-]+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]]-name = "windows-strings"-version = "0.4.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"-dependencies = [- "windows-link 0.1.3",+name = "simd-adler32"+version = "0.3.10"+ "unicode-ident", ] [[package]]-name = "windows-strings"-version = "0.5.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"-dependencies = [- "windows-link 0.2.1",+name = "thiserror"+version = "2.0.20"+ "syn 3.0.5", ] [[package]]-name = "windows-sys"-version = "0.52.0"+name = "uiautomation"+version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"-dependencies = [- "windows-targets",-]+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]]-name = "windows-sys"-version = "0.59.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"-dependencies = [+name = "version_check"+version = "0.9.5" "windows-targets", ] -[[package]]-name = "windows-sys"-version = "0.61.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"-dependencies = [- "windows-link 0.2.1",-]- [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"-dependencies = [- "windows_aarch64_gnullvm",- "windows_aarch64_msvc",- "windows_i686_gnu",- "windows_i686_gnullvm",- "windows_i686_msvc",- "windows_x86_64_gnu",- "windows_x86_64_gnullvm",- "windows_x86_64_msvc",-]--[[package]]-name = "windows-threading"-version = "0.1.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"-dependencies = [- "windows-link 0.1.3",-]--[[package]]-name = "windows-threading"-version = "0.2.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"-dependencies = [- "windows-link 0.2.1",-]--[[package]]-name = "windows_aarch64_gnullvm"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"--[[package]]-name = "windows_aarch64_msvc"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"--[[package]]-name = "windows_i686_gnu"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"--[[package]]-name = "windows_i686_gnullvm"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"--[[package]]-name = "windows_i686_msvc"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"--[[package]]-name = "windows_x86_64_gnu"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"--[[package]]-name = "windows_x86_64_gnullvm"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"--[[package]]-name = "windows_x86_64_msvc"-version = "0.52.6"-source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]]-name = "winnow"-version = "1.0.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"-dependencies = [- "memchr",-]- [[package]] name = "winreg" version = "0.55.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"-dependencies = [- "cfg-if", "windows-sys 0.59.0", ] -[[package]]-name = "x11rb"-version = "0.13.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"-dependencies = [- "gethostname",- "rustix",- "x11rb-protocol",-]--[[package]]-name = "x11rb-protocol"-version = "0.13.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"- [[package]] name = "xmlwriter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" -[[package]]-name = "zbus"-version = "5.19.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"-dependencies = [- "async-broadcast",- "async-executor",- "async-io",- "async-lock",- "async-process",- "async-recursion",- "async-task",- "async-trait",- "blocking",- "enumflags2",- "event-listener",- "futures-core",- "futures-lite",- "hex",- "libc",- "ordered-stream",- "rustix",- "serde",- "serde_repr",- "tracing",- "uds_windows",- "uuid",- "windows-sys 0.61.2",- "winnow",- "zbus_macros",- "zbus_names",- "zvariant",-]--[[package]]-name = "zbus_macros"-version = "5.19.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"-dependencies = [- "proc-macro-crate",- "proc-macro2",- "quote",- "syn 3.0.5",- "zbus_names",- "zvariant",- "zvariant_utils",-]--[[package]]-name = "zbus_names"-version = "4.3.5"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"-dependencies = [- "serde",- "winnow",- "zvariant",-]--[[package]]-name = "zcheapstr"-version = "1.2.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"-dependencies = [- "serde",-]- [[package]] name = "zeroize" version = "1.9.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"--[[package]]-name = "zlib-rs"-version = "0.6.7"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"--[[package]]-name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"--[[package]]-name = "zvariant"-version = "5.15.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"-dependencies = [- "endi",- "enumflags2",- "serde",- "winnow",- "zcheapstr",- "zvariant_derive",- "zvariant_utils",-]--[[package]]-name = "zvariant_derive"-version = "5.15.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"-dependencies = [- "proc-macro-crate",- "proc-macro2",- "quote",- "syn 3.0.5",- "zvariant_utils",-]--[[package]]-name = "zvariant_utils"-version = "4.2.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"-dependencies = [- "proc-macro2",- "quote",- "serde",- "syn 3.0.5",- "winnow",-]+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
rust/README.md+1−57@@ -1,62 +1,6 @@-# kicad-bridge (native)--The Rust port of the Adom KiCad Bridge. One binary per OS, no runtime to provision. Plan and phases: [docs/rust-port-plan.md](../docs/rust-port-plan.md).--## Layout | Crate | What | OS-specific? | |---|---|---|-| `crates/kicad-platform` | The `Platform` trait and exactly one implementation compiled in per target: `windows.rs` (+ `win/`), `macos.rs` (Kyle), `linux.rs` (+ `linux/x11.rs` for windows and screenshots, `linux/atspi.rs` for menus, dialogs and buttons through the accessibility bus). Install discovery, settings and documents dirs, quiet child processes; later window control, UI automation, dialog sweep and focus etiquette. | Yes, this is the only crate that is |+| `crates/kicad-platform` | The `Platform` trait and exactly one implementation compiled in per target: `windows.rs`, `macos.rs` (Kyle), `linux.rs` (Barrett). Install discovery, settings and documents dirs, quiet child processes; later window control, UI automation, dialog sweep and focus etiquette. | Yes, this is the only crate that is | | `crates/kicad-core` | Detection ordering, the kicad-cli runner and report parsers, KiCad file editing (phase 1b), IPC client (phase 2). Talks to the OS only through `kicad_platform::native()`. | No | | `crates/kicad-bridge` | The binary: HTTP server (`/status`, `/command`), the verb catalog and the verb groups (one file per group, registered in `groups.rs`). | No |--A verb that needs something the current OS does not implement yet returns `kicad_platform::unsupported("...")`: `errorCode: not_supported_on_platform`, the OS, and the owner's name in the hint. Readiness and diagnostics carry a `platform.capabilities` block so an AI can tell what this build can do before calling anything.--## Build--```-cargo build --release # host OS-cargo build --release --target x86_64-pc-windows-gnu # Windows, from Linux (mingw-w64 installed)-cargo check --target aarch64-apple-darwin --no-default-features # type-check the macOS file from a box without a darwin C toolchain (net feature off)-```--Per-OS dependencies go in `crates/kicad-platform/Cargo.toml` under the matching `[target.'cfg(...)'.dependencies]` table. Nothing OS-specific belongs in the other two crates.--## Run by hand--```-kicad-bridge --port 47731-curl http://127.0.0.1:47731/status-curl -X POST http://127.0.0.1:47731/command -d '{"command":"kicad_describe"}'-```--ab spawns it with `spawn.kind: "exe"` and appends `--port`; bind host comes from `ADOM_BIND_HOST`.--## Name verbs from the EDA Bridge Standard--The KiCad, Altium and Fusion bridges share one set of verb names and one board model, the-[EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard), so one AI and the shared checks work on all-three. Before you name a new verb, look its job up in `rust/crates/kicad-bridge/src/standard_names.txt` (the-standard's verbs; `verbs/standard.json` on the standard's page has each one's summary, inputs and reply):--- **The standard has the job**: name the verb after it (`kicad_net_classes`, not `kicad_netclasses`), or keep your- name and add `(standard name, your verb)` to `catalog::ALIASES`, so both names are served.-- **It covers part of the job**: keep your name, list the verb in `kicad_only.txt`, and record it as partial in the- standard's `tools/equivalents/kicad.json`.-- **The standard has no such job**: list the verb in `rust/crates/kicad-bridge/src/kicad_only.txt`. If other EDA- tools could do the job, propose it to the standard (an issue titled `kicad-lane: propose ...` on its page; once- the standard-lane thread agrees, the entry goes in `tools/proposed/kicad-lane.json`). When it lands, take the- verb out of `kicad_only.txt` and give it the standard's arguments in `std_shapes.rs`.--`catalog.rs`'s test `every_verb_has_a_standard_decision` fails until every verb is one of the three, and-`bridge_json_declares_exactly_the_catalog` fails until `bridge.json` lists it. Each new verb also needs an entry in-the verb battery (`skills/kicad-bridge-test/run_verb_tests.py`): the battery fails a catalog verb it does not test.-`tools/build_native_release.py --publish` then syncs `bridge.json` and pushes `reference/kicad-<ver>.json` to the standard; the standard-lane thread regenerates the coverage (ping it, pinned issue #3 there).--## Adding a verb--0. Pick its name from the EDA Bridge Standard (above) and record the decision.-1. Add a `Verb` entry to `catalog.rs` (summary, mechanism, risk, timeout, input, example, hint, related, pitfalls). ab's Verbs tab renders it verbatim.-2. Add the match arm in `verbs.rs::dispatch`.-3. Add it to `bridge.json` and give it an entry in the verb battery (`skills/kicad-bridge-test/run_verb_tests.py`).-4. Keep the Python contract: same arg names, same `success`/`output`/`data` shape, so the verb runner in `skills/kicad-bridge-test` passes unchanged.
rust/crates/kicad-bridge/src/main.rs+1−288@@ -1,293 +1,6 @@-//! Adom KiCad Bridge, native binary. Phase 1 of docs/rust-port-plan.md.-//!-//! Contract (same as the Python bridge and the hello-rust sample):-//! - `--port N` from ab, bind ADOM_BIND_HOST (never 0.0.0.0)-//! - GET /status -> health JSON with the optional led/summary/tooltip chip-//! - POST /command {"command":"kicad_x","args":{}} -> {"success","output"|"error",...}-//! Every verb lives in the catalog (catalog.rs) so describe, routing and risk-//! come from one table.--mod catalog;-mod groups;-mod util;-mod verbs;-mod verbs_board;-mod verbs_demo;-mod verbs_fab;-mod verbs_libedit;-mod verbs_project;-mod verbs_install;-mod taskbar;-mod verbs_libraries;-mod verbs_lint;-mod verbs_maint;-mod verbs_misc;-mod verbs_netlist;-mod verbs_placement;-mod verbs_place_helpers;-mod verbs_pcb_sync;-mod verbs_route_edit;-mod verbs_rules;-mod verbs_routing;-mod verbs_schematic;-mod verbs_sch_edit;-mod verbs_sch_hier;-mod verbs_show;-mod verbs_standard;-mod std_shapes;-mod verbs_composite;-mod verbs_checks;-mod verbs_checks_native;-mod verbs_report;-mod verbs_order;-mod verbs_land_pattern;-mod verbs_library_views;-mod verbs_sim;-mod verbs_app;-mod verbs_interop;-mod verbs_std_more;-mod verbs_unsupported;-mod verbs_build;-mod verbs_windows;--use std::sync::atomic::{AtomicU64, Ordering};-use std::sync::Arc;-use std::time::Instant;--use serde::Deserialize;-use serde_json::{json, Value};-use tiny_http::{Header, Method, Response, Server, StatusCode};--pub const VERSION: &str = env!("CARGO_PKG_VERSION");-pub const SLUG: &str = "adom/kicad-bridge";--#[derive(Debug, Deserialize)]-struct Command {- #[serde(default)]- command: String,- #[serde(default)]- args: Value,-}--/// Error codes that mean the call itself needs changing, not the bridge.-pub fn caller_side(code: &str) -> bool {- const EXACT: &[&str] = &["bad_arg", "bad_args", "file_not_found", "unsaved_changes", "stale_board", "revision_required", "drc_rejected",- "no_model", "reason_required", "not_supported", "unsupported_platform", "unknown_verb", "unknown_layer", "unknown_rule", "unknown_key",- "unknown_action", "wrong_file_type", "file_exists", "footprint_exists", "ambiguous", "ambiguous_board", "already_there", "already_flagged",- "needs_closed_board", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",- "invalid_path", "invalid_scope", "name_required", "engine_required", "foreground_required", "nothing_to_route", "nothing_in_box",- "nothing_placed", "side_change_not_supported", "live_move_drops_units", "entry_off_bus", "bad_via_geometry"];- EXACT.contains(&code) || code.starts_with("missing_") || code.starts_with("no_such_") || code.ends_with("_not_found")- || code.ends_with("_not_open") || code.ends_with("_required")-}--pub fn report_issues_line() -> String {- format!(- "THIS MAY BE A KICAD BRIDGE BUG. Check for a duplicate with `adom-wiki issue list {SLUG}`, \- then file: `adom-wiki issue create {SLUG} --title \"<symptom>\" --category bug-report \- --body \"<what you ran, expected, got, the verbatim response JSON, bridge version, OS>\"`. \- An ab-core problem (relay, lifecycle, identity) belongs on adom/adom-bridge instead. \- Had to work around it? Say so in the issue. Fixed it yourself? The source is this page's repo: \- `adom-wiki repo clone {SLUG}`, build with `cargo test` and `cargo build --release --target x86_64-pc-windows-gnu`, \- then send a branch PR off the current head: `adom-wiki branch create {SLUG} fix/<what>`, \- `adom-wiki repo push {SLUG} --branch fix/<what> --files ...`, `adom-wiki pr create {SLUG} --from fix/<what> --title ...` \- (skill kicad-bridge-contributing; PRs are merged within minutes in business hours)."- )-}--pub struct Caller {- pub thread: String,- pub container: String,- pub reason: String,-}--fn header_value(req: &tiny_http::Request, name: &str) -> Option<String> {- req.headers()- .iter()- .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))- .map(|h| h.value.as_str().to_string())-}--fn parse_port_arg() -> Option<u16> {- let args: Vec<String> = std::env::args().collect();- let mut i = 1;- while i < args.len() {- if args[i] == "--port" && i + 1 < args.len() {- return args[i + 1].parse().ok();- }- if let Some(v) = args[i].strip_prefix("--port=") {- return v.parse().ok();- }- i += 1;- }- None-}--fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> {- let text = body.to_string();- Response::from_string(text)- .with_status_code(StatusCode(code))- .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())-}--/// Requests are served by a small pool: ab polls /status every 15 s and the AI polls-/// kicad_progress while a long verb runs, and neither may wait behind it (the Python bridge-/// was a ThreadingHTTPServer for the same reason). Serialisation of the verbs that need it-/// happens on util::GUI_LOCK inside dispatch.-const WORKERS: usize = 6;--fn main() {- // kicad_simulate runs ngspice in a child of this binary: one job from stdin, the result to stdout- if std::env::args().any(|a| a == "--ngspice-worker") {- std::process::exit(ngspice_worker());- }- let port = parse_port_arg().unwrap_or(0);- let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());- let started = Instant::now();- let request_count = Arc::new(AtomicU64::new(0));- let verb_count = Arc::new(AtomicU64::new(0));-- let server = Server::http((host.as_str(), port))- .unwrap_or_else(|e| panic!("[kicad-bridge] bind {host}:{port} failed: {e}"));- let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);- eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port} ({WORKERS} workers)");-- kicad_platform::native().init_process();- kicad_platform::native().etiquette_start();- verbs_libraries::apply_background_guard();- taskbar::boot_sweep();- // The white taskbar icon (kicad_taskbar_repair): KiCad's own AUMID onto its standalone- // shortcuts, once per boot, off the request path. Idempotent and never fatal.- std::thread::spawn(|| {- let version = util::State::new().kicad_info().version;- let r = verbs_maint::taskbar_repair(&version);- if r["written"].as_u64().unwrap_or(0) > 0 || r["errors"].as_u64().unwrap_or(0) > 0 {- eprintln!("[kicad-bridge] taskbar repair: {}", r["output"].as_str().unwrap_or(""));- }- });- let server = Arc::new(server);- let mut handles = Vec::new();- for _ in 0..WORKERS {- let server = Arc::clone(&server);- let request_count = Arc::clone(&request_count);- let verb_count = Arc::clone(&verb_count);- handles.push(std::thread::spawn(move || loop {- let req = match server.recv() {- Ok(r) => r,- Err(e) => {- eprintln!("[kicad-bridge] recv failed: {e}");- break;- }- };- let n = request_count.fetch_add(1, Ordering::Relaxed) + 1;- handle(req, started, n, &verb_count);- }));- }- for h in handles {- let _ = h.join();- }-}--fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, verb_count: &AtomicU64) {- let uptime = started.elapsed().as_secs();- let mut state = util::State::new();- let (body, code) = match (req.method(), req.url()) {- (Method::Get, "/status") | (Method::Get, "/health") => {- let info = state.kicad_info();- let led = if info.installed { "green" } else { "yellow" };- (- json!({- "ok": true,- "bridge": "kicad",- "version": VERSION,- "runtime": "native",- "uptimeSec": uptime,- "requestCount": request_count,- "led": led,- "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },- "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),- }),- 200,- )- }- (Method::Post, "/command") => {- let caller = Caller {- thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),- container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),- reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),- };- let mut raw = String::new();- let _ = req.as_reader().read_to_string(&mut raw);- if raw.trim().is_empty() {- raw = "{}".into();- }- match serde_json::from_str::<Command>(&raw) {- Err(e) => (- json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),- 400,- ),- Ok(cmd) => {- let nth = verb_count.fetch_add(1, Ordering::Relaxed) + 1;- eprintln!(- "[kicad-bridge] {} <- thread={} container={} reason={}",- cmd.command, caller.thread, caller.container, caller.reason- );- let t0 = Instant::now();- let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);- if let Some(o) = out.as_object_mut() {- o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));- o.entry("bridgeVersion").or_insert(json!(VERSION));- let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);- // a refusal the caller fixes (an argument, a name, a file, a stale revision, a DRC finding) is not a- // bridge bug: the "file an issue" line stays off it- let callers = o.get("errorCode").and_then(Value::as_str).is_some_and(caller_side);- if (failed && !callers) || nth == 1 {- o.insert("_reportIssues".into(), json!(report_issues_line()));- }- }- (out, 200)- }- }- }- (Method::Post, "/demo/event") => {- // ab's demo panel posts every button press here (phase 5); the tour applies it.- let mut raw = String::new();- let _ = req.as_reader().read_to_string(&mut raw);- (verbs_demo::panel_event(&raw), 200)- }- _ => (json!({"success": false, "error": "not found"}), 404),- };- let _ = req.respond(json_response(body, code));-}- /// `--ngspice-worker`: `{dll, cwd, lines, codemodels, commands?}` on stdin, kicad_platform::ngspice::run's answer (or `{error}`) on stdout. fn ngspice_worker() -> i32 { use std::io::Read;- kicad_platform::ngspice::limit_worker_memory(1536 * 1024 * 1024); let mut input = String::new(); let _ = std::io::stdin().read_to_string(&mut input);- let job: Value = serde_json::from_str(&input).unwrap_or(Value::Null);- let lines: Vec<String> = job["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(String::from)).collect();- let cms: Vec<std::path::PathBuf> = job["codemodels"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(std::path::PathBuf::from)).collect();- let cmds: Vec<String> = job["commands"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(String::from)).collect();- let out = kicad_platform::ngspice::run(std::path::Path::new(job["dll"].as_str().unwrap_or("")), &lines, std::path::Path::new(job["cwd"].as_str().unwrap_or(".")), &cms, &cmds);- let v = match out {- Ok(v) => v,- Err(e) => serde_json::json!({"error": e}),- };- println!("{v}");- 0-}--#[cfg(test)]-mod tests {- #[test]- fn a_callers_own_mistake_is_not_reported_as_a_bridge_bug() {- for c in ["bad_args", "missing_arg", "no_such_probe", "footprint_not_found", "pcb_editor_not_open", "reason_required", "drc_rejected", "no_model"] {- assert!(super::caller_side(c), "{c}");- }- for c in ["internal", "handler_panicked", "write_failed", "simulation_failed", "worker_failed", "bad_layout", "ipc_unavailable"] {- assert!(!super::caller_side(c), "{c}");- }- }-}+ let job: Value = serde_json::from_str(&input).unwrap_or(Value::Null);
rust/crates/kicad-bridge/src/verbs_maint.rs+4−364@@ -1,379 +1,19 @@-//! Verb group "maint": kicad_model_check, kicad_pcm_list, kicad_pcm_install,-//! kicad_pcm_uninstall, kicad_pcm_add_repository, kicad_pcm_remove_repository,-//! kicad_check_for_updates, kicad_upgrade. Ported from-//! handlers/model_check.py, handlers/pcm.py and both halves of handlers/upgrade.py (the-//! installer orchestration lives in kicad_core::install_kicad).-use std::time::Duration;--use serde_json::{json, Value};--use crate::catalog::{Mechanism, Verb};-use crate::util::State;-use kicad_core::model_check::{self, Ctx};-use kicad_core::{install_kicad, pcm, updates};-use kicad_platform::native;--pub static VERBS: &[Verb] = &[- Verb {- name: "kicad_3d_viewer_settings",- summary: "Read or set the 3D viewer's model-visibility toggles (SMD, through-hole, virtual, not-in-POS, DNP) that can hide every chip body.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 30,- input: "{\"smd\"?: bool, \"throughHole\"?: bool, \"virtual\"?: bool, \"notInPosFile\"?: bool, \"dnp\"?: bool}",- example: "kicad_3d_viewer_settings {\"smd\": true}",- hint: "No args reads <config>/3d_viewer.json render.show_footprints_*. These toggles hide whole classes of footprint bodies in the 3D viewer AND in kicad-cli pcb render, so a board whose every model resolves can still show pads only (insert is KiCad's word for SMD, normal for through-hole). Setting one refuses while KiCad runs, because KiCad rewrites the file from memory when it exits: kicad_close, set, then kicad_show_3d_board again. Other keys are preserved and the previous file is kept as 3d_viewer.json.adom.bak.",- related: &["kicad_model_check", "kicad_show_3d_board", "kicad_show_3d_chip", "kicad_close"],- pitfalls: &["kicad_model_check reports the same flags with a _viewerHint when a hidden class exists on the board: read that before you bisect a board.", "These are the USER's viewer preferences: change them because they asked or because the proof needs it, and say that you did."],- },- Verb {- name: "kicad_taskbar_repair",- summary: "Fix the white taskbar icon on standalone KiCad editors by writing KiCad's own AUMID onto its Start Menu shortcuts.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 30,- input: "{}",- example: "kicad_taskbar_repair {}",- hint: "KiCad's installer leaves the AppUserModelID blank on the standalone PCB Editor, Schematic Editor and Gerber Viewer shortcuts, so a pcbnew.exe the bridge launches (and its 3D viewer, same process) can show the generic white page on the taskbar. This writes KiCad's OWN AUMID (Kicad.Kicad.pcbnew.<ver>) onto KiCad's OWN user-level shortcuts, nothing of Adom's, and runs by itself at every bridge start. Idempotent. The shell reads the mapping when it next rebuilds its Start Menu index: a newly opened editor usually picks it up; a button that stays white needs a logon or an explorer restart, which the bridge never does for you.",- related: &["kicad_readiness", "kicad_open_board", "kicad_launch"],- pitfalls: &["Never stamp Adom identity on KiCad windows: Windows caches the taskbar icon per AUMID string and a bad stamp is permanent (skills/kicad-bridge-dev, THE WHITE-ICON MECHANISM).", "Needs a detected KiCad (the shortcuts are named by major.minor)."],- },- Verb {- name: "kicad_prewarm",- summary: "Get this machine ready for a fast first KiCad call: detect KiCad, seed its first-run config, mark its setup wizard done, repair its taskbar shortcuts. Idempotent.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 120,- input: "{}",- example: "kicad_prewarm {}",- hint: "Adom Bridge calls this once per installed bridge version, in the background, about 20 s after the first boot that follows an install or update (bridge.json prewarm.verb, ab 2.1.153); bridge_prewarm {\"name\":\"kicad\",\"force\":true} runs it again. Everything it does is what the first real verb would otherwise pay for: kicad-cli is run once (detection), and the config steps only happen while no KiCad is running, because KiCad rewrites its own settings on exit. Never downloads KiCad: kicad_upgrade does that, and asks first.",- related: &["kicad_readiness", "kicad_taskbar_repair", "kicad_upgrade"],- pitfalls: &["With KiCad running the config steps are skipped (reported), not forced: run it again with KiCad closed.", "No KiCad installed answers ok with nothing done; bridge.json declares requiresHostApp so ab normally skips such a box."],- },- Verb {- name: "kicad_model_check",- summary: "Read-only 3D model diagnostics for a footprint or a whole board: which (model ...) references exist, where KiCad looks, whether each file exists and holds geometry.",- mechanism: Mechanism::File, risk: "read", timeout_sec: 60,- input: "{\"library\": \"Adom\", \"footprintName\": \"TMF8829-1A\", \"projectDir\"?: \"C:/...\"} or {\"boardPath\": \"C:/x/y.kicad_pcb\", \"limit\"?: 50}",- example: "kicad_model_check {\"library\":\"Adom\",\"footprintName\":\"TMF8829-1A\"}",- hint: "kicad_model_check {\"library\":\"Adom\",\"footprintName\":\"TMF8829-1A\"} or {\"boardPath\":\"C:/x/y.kicad_pcb\"}. Warnings carry codes (no_model_reference, not_found_relative, unresolved_variable, file_missing, unreadable, not_step_or_vrml, empty_geometry, hidden, implausible_scale). renderVerified is always false: the viewer frame is the render proof.",- related: &["kicad_show_3d_chip", "kicad_show_3d_board", "kicad_install_footprint"],- pitfalls: &[- "a bare relative model path is never looked up next to the .kicad_mod: KiCad tries the project dir and the 3D search paths",- "a resolvable file is not a rendered body; read the 3D frame",- ],- },- Verb {- name: "kicad_pcm_list",- summary: "Packages KiCad's PCM considers installed on this machine, each with updateAvailable from its own repository (read-only).",- mechanism: Mechanism::File, risk: "read", timeout_sec: 60,- input: "{\"checkUpdates\"?: true}",- example: "kicad_pcm_list {}",- hint: "Reads installed_packages.json, checks the 3rdparty directories exist, then fetches each package's repository (matched by repository_id against kicad.json pcm.repositories) and compares the newest compatible version with the installed one: updateAvailable, latestVersion, updateCheck per package, updatesAvailable in total. checkUpdates:false skips the network. To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",- related: &["kicad_pcm_install", "kicad_pcm_add_repository"],- pitfalls: &[- "updateCheck repository_not_registered means the package's repository is not in kicad.json (KiCad's own PCM cannot offer the update either): kicad_pcm_add_repository fixes both",- "a pinned package reports updateAvailable false with updateCheck pinned, as PCM does",- ],- },- Verb {- name: "kicad_pcm_install",- summary: "Install or update a package from a KiCad PCM repository on disk, exactly as the Plugin and Content Manager would (no dialog).",- mechanism: Mechanism::File, risk: "write", timeout_sec: 900,- input: "{\"repositoryUrl\"?: \"https://.../repository.json\" | \"file:///C:/.../repository.json\", \"identifier\": \"com.adom.kicad-bridge.sample-lib\", \"version\"?: \"1.1.0\", \"update\"?: bool, \"libPrefix\"?: \"PCM_\", \"force\"?: bool} | {\"zipPath\": \"C:/.../package.zip\"}",- example: "kicad_pcm_install {\"repositoryUrl\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\",\"identifier\":\"com.adom.kicad-bridge.sample-lib\"}",- hint: "repositoryUrl (a repository.json, https or file://) + identifier installs the newest compatible version (or version). A private Adom wiki page that answers 404 to an anonymous read is fetched again with the user's wiki session through Adom Bridge. zipPath installs one local PCM package zip (its metadata.json gives the identifier), with the same registry row; KiCad cannot check such a package for updates. update:true is PCM's Update button: repositoryUrl may be omitted for an installed package (its own repository is used), an installed newest version answers upToDate, otherwise the old files are replaced and installed_packages.json rewritten (previousVersion, updated:true). Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done). To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",- related: &["kicad_pcm_list", "kicad_pcm_uninstall", "kicad_pcm_add_repository", "kicad_install_library_bundle"],- pitfalls: &[- "a RUNNING KiCad does not see the new libraries until restarted",- "download is sha256-verified against the repository; a mismatch refuses",- "an update removes the old package directories first; keep_on_update patterns are not honoured by the bridge",- "kicadRunning is a real process scan: restartNeeded is true only when KiCad is actually running, or when the scan is unavailable (kicadRunning null, the safe answer)",- ],- },- Verb {- name: "kicad_pcm_uninstall",- summary: "Remove a PCM-installed package: its 3rdparty directories and its record.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 120,- input: "{\"identifier\": \"inc.adom.kicad.basic-parts\"}",- example: "kicad_pcm_uninstall {\"identifier\":\"inc.adom.kicad.basic-parts\"}",- hint: "KiCad drops the PCM_ library rows on next load when pcm.lib_auto_remove is on.",- related: &["kicad_pcm_install", "kicad_pcm_list"],- pitfalls: &["restart a running KiCad afterwards"],- },- Verb {- name: "kicad_pcm_add_repository",- summary: "Register a PCM repository in kicad.json (pcm.repositories), the way Plugin and Content Manager > Manage > add URL does.",- mechanism: Mechanism::File, risk: "config", timeout_sec: 60,- input: "{\"url\": \"https://.../repository.json\", \"name\"?: \"Adom KiCad Bridge sample PCM\"}",- example: "kicad_pcm_add_repository {\"url\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\"}",- hint: "Fetches the repository.json to confirm it is a PCM repository and to take its name (name overrides, and registers it unverified when the fetch fails). Idempotent on the URL. Writes kicad.json with a .adom-bak beside it and an atomic replace; when kicad.json had no pcm.repositories key, KiCad's built-in official repository row is kept in front. KiCad reads the list at start: restartNeeded is always true. To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",- related: &["kicad_pcm_remove_repository", "kicad_pcm_install", "kicad_pcm_list"],- pitfalls: &[- "a KiCad that is running NOW overwrites kicad.json when it saves settings on exit: close it first when in doubt",- "kicadRunning says whether KiCad holds the file right now; null means this OS layer has no process scan, so close KiCad yourself when in doubt",- ],- },- Verb {- name: "kicad_pcm_remove_repository",- summary: "Drop a PCM repository from kicad.json pcm.repositories. Installed packages stay installed.",- mechanism: Mechanism::File, risk: "config", timeout_sec: 30,- input: "{\"url\": \"https://.../repository.json\"}",- example: "kicad_pcm_remove_repository {\"url\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\"}",- hint: "Same backup and atomic write as add; removed:false when the URL was not registered. Packages from that repository stay installed and lose their update checks.",- related: &["kicad_pcm_add_repository", "kicad_pcm_list"],- pitfalls: &["KiCad reads the list at start; a running KiCad keeps the old list until relaunched"],- },- Verb {- name: "kicad_tool_update",- summary: "KiCad's own updates (the standard's tool_update): check whether a newer stable KiCad is offered, apply it with the official installer when KiCad is closed, or read or set KiCad's own automatic update check.",- mechanism: Mechanism::Cli, risk: "exec", timeout_sec: 900,- input: "{\"action\": \"check\"|\"apply\"|\"auto\", \"on\"?: bool (auto: turn KiCad's update check on or off; omitted, it is read)}",- example: "kicad_tool_update {\"action\":\"check\"}",- hint: "check answers current, offered (null when current) and auto. apply runs kicad_upgrade for the offered version: a silent per-user install, no UAC, refused while KiCad runs (the open documents are listed, unsaved ones marked: save and kicad_close first). Updating KiCad changes the user's machine: say so before apply. auto is KiCad's 'check for updates' setting (kicad.json system.check_for_kicad_updates); KiCad itself only offers updates, never installs them.",- related: &["kicad_check_for_updates", "kicad_upgrade", "kicad_close", "kicad_readiness"],- pitfalls: &["apply refuses while KiCad runs: it never closes unsaved work.", "auto refuses while KiCad runs too: KiCad writes its settings back when it exits, over the change."],- },- Verb {- name: "kicad_check_for_updates",- summary: "Read-only: installed KiCad vs latest stable.",- mechanism: Mechanism::File, risk: "read", timeout_sec: 45,- input: "{\"probeInstaller\"?: bool}",- example: "kicad_check_for_updates {}",- hint: "Compares the installed KiCad to the latest stable on downloads.kicad.org; flags pre-IPC installs. probeInstaller:true adds downloadBytes (a HEAD on the installer URL).",- related: &["kicad_upgrade", "kicad_list_versions"],- pitfalls: &["read-only: it does NOT install; run kicad_upgrade to apply"],- },- Verb {- name: "kicad_upgrade",- summary: "Install/upgrade KiCad via the official installer (agent installs for the user).",- mechanism: Mechanism::Cli, risk: "exec", timeout_sec: 900,- input: "{\"version\"?: \"10.0.6\", \"scope\"?: \"allusers\"|\"currentuser\", \"diagnoseOnly\"?: bool, \"force\"?: bool, \"reDownload\"?: bool}",- example: "kicad_upgrade {}",- hint: "Downloads the official downloads.kicad.org installer + silent /S installs; installs from scratch too. The agent does this FOR the user: never hand them a manual download. Default scope is currentuser (per-user, NO admin, NO UAC) unless the bridge runs elevated; the installer is cached under <LOCALAPPDATA>/Adom Bridge/kicad-installers and reused when its size matches the server's. diagnoseOnly:true returns the elevation/UAC facts and cache state without downloading or running anything. Refuses while kicad.exe, pcbnew.exe or eeschema.exe run: kicad_close first.",- related: &["kicad_check_for_updates", "kicad_readiness", "kicad_list_versions", "kicad_close"],- pitfalls: &[- "Windows only: macOS and Linux get unsupported_platform with the OS package path named (installerKind dmg / distro-package)",- "scope allusers needs an elevated bridge; a limited token gets NsisMultiUser rc=666661 (elevation restricted), bare /S would be rc=666660 (invalid parameters), so the bridge always passes a scope flag",- "blocks until the installer finishes (a ~1 GB download plus a few minutes of install; 900 s deadline, killed on timeout)",- "never run mid-design-work: the verb refuses while KiCad is running rather than installing over open files",- "the first GUI open after install is wizard-free: the new version's kicad_common.json, eeschema.json, kicad.json and lib tables are seeded right after the install",- ],- },-];--pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {- let out = match command {- "kicad_taskbar_repair" => taskbar_repair(&state.kicad_info().version),- "kicad_prewarm" => prewarm(),- "kicad_3d_viewer_settings" => {- let ctx = Ctx::from_info(&state.kicad_info());- let sets = model_check::VIEWER_FLAGS.iter().any(|(k, _, _)| args.get(*k).map(|v| !v.is_null()).unwrap_or(false));- let (running, _, _, pids) = install_kicad::running_report();- if sets && running == json!(true) {- json!({"success": false, "errorCode": "kicad_running", "mutated": false, "kicadPids": pids, "viewer": model_check::viewer_visibility(&ctx),- "error": "KiCad is running and rewrites 3d_viewer.json from memory when it exits, which would undo this edit.",- "_hint": "kicad_close (it refuses on unsaved work rather than lose it), call this verb again, then reopen the board and kicad_show_3d_board."})- } else {- model_check::set_viewer_visibility(&ctx, args)- }- }- "kicad_model_check" => {- let ctx = Ctx::from_info(&state.kicad_info());- model_check::handle(&ctx, args)- }- "kicad_pcm_list" => pcm::list(&Ctx::from_info(&state.kicad_info()), args, &pcm::http_fetch),- "kicad_pcm_install" => {- let ctx = Ctx::from_info(&state.kicad_info());- pcm::install(&ctx, args, &pcm::http_fetch)- }- "kicad_pcm_uninstall" => pcm::uninstall(&Ctx::from_info(&state.kicad_info()), args),- "kicad_pcm_add_repository" => pcm::add_repository(&Ctx::from_info(&state.kicad_info()), args, &pcm::http_fetch),- "kicad_pcm_remove_repository" => pcm::remove_repository(&Ctx::from_info(&state.kicad_info()), args),- "kicad_check_for_updates" => {- let info = state.kicad_info();- let current = info.primary().map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() });- let probe = args.get("probeInstaller").and_then(Value::as_bool).unwrap_or(false);- let mut out = updates::check_for_updates(current.as_deref(), &updates::http_fetch, None);- if probe {- let url = out.get("downloadUrl").and_then(Value::as_str).unwrap_or("").to_string();- out["downloadBytes"] = json!(updates::remote_installer_size(&url, Duration::from_secs(15)));- }- out- }- "kicad_tool_update" => tool_update(state, args),- "kicad_upgrade" => {- let info = state.kicad_info();- let out = install_kicad::handle(&info.installs, args, &updates::http_fetch);- // server.py: a successful install refreshes the detection cache so open_*,- // list_versions and readiness see the new KiCad without a bridge restart.- if out.get("upgraded").and_then(Value::as_bool).unwrap_or(false) {- state.refresh();- }- out- }- _ => return None,- };- Some(out)-}---/// Wiki: the white taskbar icon. Also run once at boot from main.rs.-/// `kicad_prewarm` (wiki #117): everything a first real verb would otherwise wait for, once.-pub fn prewarm() -> Value {- let started = std::time::Instant::now();- let info = kicad_core::detect::detect(); // runs kicad-cli version: the CLI is warm afterwards- let mut steps = vec![json!({"step": "detect", "ok": true, "installs": info.installs.iter().map(|i| json!({"version": i.version, "cli": i.cli_version})).collect::<Vec<_>>()})];- if info.installs.is_empty() {- return json!({"success": true, "ok": true, "warmed": false, "steps": steps, "seconds": started.elapsed().as_secs_f64(),- "output": "No KiCad on this machine: nothing to prewarm.",- "_hint": "kicad_upgrade installs KiCad (it asks the user first); prewarm again afterwards."});- }- let running = !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty();- for inst in &info.installs {- let Some(cfg) = native().config_dir(&inst.version) else { continue };- if running { steps.push(json!({"step": "config", "version": inst.version, "ok": true, "skipped": "KiCad is running; it rewrites its own settings on exit"})); continue; }- let tables = kicad_core::install::ensure_global_tables(&cfg, Some(inst.base_dir.as_str()).filter(|b| !b.is_empty()), &inst.version); let seeded = kicad_core::kicad_common::seed_first_run(&cfg); let wizard = kicad_core::kicad_common::mark_first_run_done(&cfg); steps.push(json!({"step": "config", "version": inst.version, "ok": seeded.errors.is_empty() && wizard.is_ok(),- "written": seeded.written, "merged": seeded.merged, "libTables": tables, "setupWizardMarkedDone": wizard.as_ref().ok(), "errors": seeded.errors, "wizardError": wizard.err()}));- }- if native().capabilities().os == "windows" {- let tb = taskbar_repair(&info.version);- steps.push(json!({"step": "taskbar", "ok": tb["success"].as_bool().unwrap_or(false), "written": tb["written"], "already": tb["already"], "missing": tb["missing"]}));- } else {- // Start Menu shortcuts and their AUMIDs are a Windows thing; nothing to repair elsewhere.- steps.push(json!({"step": "taskbar", "ok": true, "skipped": format!("no taskbar shortcuts to repair on {}", native().capabilities().os)}));+ "written": seeded.written, "merged": seeded.merged, "setupWizardMarkedDone": wizard.as_ref().ok(), "errors": seeded.errors, "wizardError": wizard.err()})); }+ let tb = taskbar_repair(&info.version);+ steps.push(json!({"step": "taskbar", "ok": tb["success"].as_bool().unwrap_or(false), "written": tb["written"], "already": tb["already"], "missing": tb["missing"]})); let ok = steps.iter().all(|s| s["ok"].as_bool().unwrap_or(false)); json!({"success": ok, "ok": ok, "warmed": true, "steps": steps, "seconds": (started.elapsed().as_secs_f64() * 100.0).round() / 100.0, "output": format!("KiCad {} ready: CLI warm, first-run config seeded, setup wizard marked done, taskbar shortcuts checked{}.", info.version, if running { " (config skipped: KiCad is running)" } else { "" })})-}--pub fn taskbar_repair(version: &str) -> Value {- let mm: String = version.split('.').take(2).collect::<Vec<_>>().join(".");- if mm.is_empty() || !mm.contains('.') {- return json!({"success": false, "errorCode": "not_installed", "error": "No KiCad version detected, so the shortcut names are unknown.", "_hint": "kicad_readiness reports install state; run again once KiCad is installed."});- }- match native().repair_taskbar_shortcuts(&mm) {- Ok(rows) => {- let count = |k: &str| rows.as_array().map(|a| a.iter().filter(|r| r["outcome"] == k).count()).unwrap_or(0);- let (written, already, missing, errors) = (count("written"), count("already"), count("missing"), count("error"));- json!({- "success": errors == 0, "version": mm, "shortcuts": rows,- "written": written, "already": already, "missing": missing, "errors": errors,- "output": format!("KiCad {mm} standalone shortcuts: {written} written, {already} already carried an AUMID, {missing} missing, {errors} error(s)."),- "_hint": if written > 0 {- "KiCad's own AUMID is now on its standalone shortcuts. A newly opened editor usually gets its real icon; a button that stays white needs a logon or an explorer restart (never done for you)."- } else if already > 0 {- "Nothing to do: the standalone shortcuts already carry an AUMID. A button that is still white needs a logon or an explorer restart, or the icon was cached under a foreign AUMID before this fix (see the bridge-dev skill)."- } else {- "No standalone shortcuts found under this user's Start Menu for that KiCad version, so there was nothing to repair." }, }) }- Err(e) => json!({"success": false, "errorCode": if e.contains("not supported") || e.contains(": not implemented on ") { "not_supported_on_platform" } else { "platform_error" }, "error": e, "_hint": "Taskbar shortcuts and their AUMIDs are a Windows feature; there is nothing to repair on this OS."}),- }-}--/// `kicad_tool_update`: the standard's tool_update over kicad_check_for_updates, kicad_upgrade and KiCad's own-/// update-check setting.-fn tool_update(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- let Some(inst) = info.primary().cloned() else {- let mut v = crate::util::fail("not_installed", "KiCad is not installed", "kicad_upgrade installs it (say so first: it changes the user's machine).");- v["current"] = Value::Null;- return v;- };- let current = if inst.cli_version.is_empty() { inst.version.clone() } else { inst.cli_version.clone() };- let settings = kicad_core::detect::config_dir(&inst.version).map(|d| d.join("kicad.json"));- let read_auto = || -> Option<bool> {- let t = std::fs::read_to_string(settings.as_ref()?).ok()?;- serde_json::from_str::<Value>(&t).ok()?.pointer("/system/check_for_kicad_updates")?.as_bool()- };- let running = || install_kicad::kicad_running().1;- let action = args.get("action").and_then(Value::as_str).unwrap_or("check");- let check = updates::check_for_updates(Some(¤t), &updates::http_fetch, None);- let offered = if check["upgradeAvailable"] == json!(true) { check["latestVersion"].clone() } else { Value::Null };- // KiCad's default is to check: the setting reads true until the user (or Adom's first-run setup) turns it off- let auto = read_auto().unwrap_or(true);- let mut out = json!({"success": true, "action": action, "current": current, "offered": offered, "applied": false, "auto": auto, "needsRestart": false});- match action {- "check" => {- if check["success"] == json!(false) {- out["offered"] = Value::Null;- out["checkError"] = check["error"].clone();- }- out["_hint"] = json!(if offered.is_null() { "KiCad is current." } else { "A newer KiCad is offered: kicad_tool_update {action:\"apply\"} installs it once KiCad is closed (say so to the user first)." });- out- }- "apply" => {- if offered.is_null() {- out["_hint"] = json!("Nothing to apply: KiCad is current.");- return out;- }- let pids = running();- if !pids.is_empty() {- let st = crate::verbs::call(state, "kicad_state", &json!({}));- let mut docs = Vec::new();- fn titles(v: &Value, out: &mut Vec<String>) {- match v {- Value::Object(m) => {- if let Some(t) = m.get("title").and_then(Value::as_str) { out.push(t.to_string()); }- m.values().for_each(|x| titles(x, out));- }- Value::Array(a) => a.iter().for_each(|x| titles(x, out)),- _ => {}- }- }- titles(&st, &mut docs);- docs.sort();- docs.dedup();- let mut v = crate::util::fail("kicad_running", "KiCad is open: the update waits until it is closed, so no work is lost", "Save the documents (a title starting with * is unsaved), kicad_close, then apply again.");- v["documents"] = json!(docs);- v["unsaved"] = json!(docs.iter().filter(|t| t.starts_with('*')).collect::<Vec<_>>());- v["current"] = json!(current);- v["offered"] = offered;- return v;- }- let r = install_kicad::handle(&info.installs, &json!({"version": offered}), &updates::http_fetch);- let applied = r.get("upgraded").and_then(Value::as_bool).unwrap_or(false);- if applied {- state.refresh();- }- if r.get("success") == Some(&json!(false)) {- let mut v = r.clone();- v["current"] = json!(current);- v["offered"] = offered;- v["applied"] = json!(false);- return v;- }- out["applied"] = json!(applied);- out["install"] = r;- out["_hint"] = json!(if applied { "KiCad was updated; it starts on the new version next time (kicad_readiness shows it)." } else { "The installer ran but the new version is not detected: kicad_upgrade {diagnoseOnly:true} shows why." });- out- }- "auto" => {- let Some(on) = args.get("on").and_then(Value::as_bool) else {- out["_hint"] = json!("auto is KiCad's own 'check for updates' setting; pass on:true or on:false to set it.");- return out;- };- if !running().is_empty() {- return crate::util::fail("kicad_running", "KiCad is open and writes its settings back when it exits, over this change", "kicad_close, then set it again.");- }- let Some(path) = settings else {- return crate::util::fail("no_config_dir", "KiCad's settings folder was not found", "Start KiCad once so it writes its settings, then set it again.");- };- let mut doc: Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| json!({}));- if !doc.is_object() { doc = json!({}); }- if !doc["system"].is_object() { doc["system"] = json!({}); }- doc["system"]["check_for_kicad_updates"] = json!(on);- if let Err(e) = std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new("."))).and_then(|_| std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap_or_default())) {- return crate::util::fail("write_failed", format!("could not write {}: {e}", path.display()), "Check the folder's permissions.");- }- out["auto"] = json!(on);- out["settings"] = json!(crate::util::norm(&path));- out["_hint"] = json!("Set. KiCad only offers updates when it starts, never installs them: kicad_tool_update {action:\"apply\"} does that.");- out- }- other => crate::util::fail("bad_args", format!("action must be check, apply or auto, not {other}"), "check is the default."),+ Err(e) => json!({"success": false, "errorCode": if e.contains("not supported") { "not_supported_on_platform" } else { "platform_error" }, "error": e}), } }
rust/crates/kicad-bridge/src/verbs_sim.rs+5−317@@ -1,332 +1,20 @@-//! Verb group "sim": SPICE from a KiCad schematic (wiki #89), headless.-//! - `kicad_export_spice`: KiCad's own SPICE netlist of a sheet (kicad-cli), and what it holds.-//! - `kicad_simulate`: that netlist with the analysis asked for, run by the ngspice library KiCad installs-//! (kicad_platform::ngspice) in a child process of this binary (`--ngspice-worker`), so a circuit that-//! brings ngspice down never takes the bridge with it; the probes come back downsampled, and as CSV.--use std::io::Write;-use std::path::{Path, PathBuf};-use std::time::{Duration, Instant};--use serde_json::{json, Value};--use crate::catalog::{Mechanism, Verb};-use crate::util::*;-use kicad_core::{cli, spice};--pub static VERBS: &[Verb] = &[- Verb {- name: "kicad_export_spice",- summary: "Export a schematic's SPICE netlist with KiCad's own exporter (kicad-cli), headless, and say what it holds: its elements, the analyses the schematic carries, and the model files it includes.",- mechanism: Mechanism::Cli, risk: "write", timeout_sec: 120,- input: "{\"schPath\": \"C:/.../x.kicad_sch\", \"outputPath\"?: \"C:/.../x.cir\" (default beside the sheet), \"format\"?: \"spice\" | \"spicemodel\"}",- example: "kicad_export_spice {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\"}",- hint: "path is the .cir; elements counts the parts that made it into the netlist. A part without a SPICE model becomes a stub with no nodes (J1 __J1) and is named in notSimulated: give it Sim fields (Simulation_SPICE sources, or the Sim.* fields in its properties) or exclude it from simulation. spicemodel writes a subcircuit instead of a top-level circuit. kicad_simulate runs it.",- related: &["kicad_simulate", "kicad_run_erc", "kicad_extract_netlist"],- pitfalls: &["Ground must be the net 0: KiCad's GND symbol makes it so.", "Model files are referenced by .include with the path the part's Sim.Library field gives."],- },- Verb {- name: "kicad_simulate",- summary: "Simulate a schematic with SPICE, headless: KiCad's netlist of the sheet run by the ngspice library KiCad installs, with the analysis you give (transient, AC, DC sweep, operating point) or the one the schematic carries, optionally stepped over a part's value, a model parameter or the temperature; each probe comes back downsampled with its first, last, min and max values (AC as dB and phase), and as a CSV file.",- mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,- input: "{\"schPath\" | \"schematic\": \"C:/.../x.kicad_sch\", \"analysis\"?: {\"type\": \"op\"} | {\"type\": \"dc\", \"source\", \"start\", \"stop\", \"step\"} | {\"type\": \"ac\", \"sweep\": \"dec|oct|lin\", \"points\", \"start\", \"stop\"} | {\"type\": \"tran\", \"step\", \"stop\", \"start\"?} | {\"type\": \"noise\", \"source\", \"output\", \"reference\"?, \"sweep\"?, \"points\"?, \"start\", \"stop\"} | {\"type\": \"raw\", \"text\": \".noise ...\"} | \".tran 10u 5m\" (or a list), \"probes\"?: [{\"net\": \"OUT\"}, {\"current\": \"V1\"}] | [\"V(/OUT)\"], \"sweep\"?: {\"parameter\": \"R4\" | \"Q1.BF\", \"start\", \"stop\", \"step\", \"scale\"?: \"lin|dec|oct\"}, \"temperatures\"?: {\"start\", \"stop\", \"step\"}, \"monteCarlo\"?: {\"runs\", \"distribution\"?: \"uniform|gaussian\", \"seed\"?, \"tolerances\": {\"resistor\"?, \"capacitor\"?, \"inductor\"?, \"dcSource\"?} (%)}, \"points\"?: 200, \"outputDir\"?: \"C:/...\", \"timeoutSec\"?: 120}", example: "kicad_simulate {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\",\"analysis\":{\"type\":\"ac\",\"sweep\":\"dec\",\"points\":20,\"start\":10,\"stop\":1000000},\"probes\":[{\"net\":\"OUT\"}]}", hint: "signals[{signal, x, y, xUnit, yUnit, phase?}] is the standard's answer (#23): each probe's own points, at most points of them with the first, last and extreme points kept; AC as dB with unwrapped phase in degrees. engine names the ngspice and KiCad versions. probes are nets ({net: \"OUT\"}, or V(/OUT)) and source currents ({current: \"V1\"}); without probes every node and branch comes back (at most 24). With sweep (a part's value \"R4\" or a model parameter \"Q1.BF\"; lin steps by step, dec/oct take step points per decade/octave) or temperatures (°C), ngspice runs once per value (alter/altermod, .temp; at most 50 runs) and each run's signals carry step (\"R4 = 300\", \"T = 85\"): group by signal + step. monteCarlo draws every resistor, capacitor, inductor and DC source whose kind has a tolerance again for each run (uniform, or gaussian with sigma tol/3), each run's step listing the values drawn (the same seed draws the same). A noise analysis answers onoise and inoise (V/rtHz) and totals {onoise, inoise} in V. The top-level sweep is time, frequency or the DC sweep of the first run. An AC probe has db and phaseDeg. csvPath holds the same points. Read log for ngspice's own messages when a run fails.", related: &["kicad_export_spice", "kicad_build_schematic", "kicad_sch_set_property"],- pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library). A part without one is left out of the run and named in notSimulated; a sheet with no modelled part refuses no_model.", "Runs through KiCad's ngspice library: ngspice.dll beside KiCad on Windows, the distribution's libngspice on Linux.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run.", "A swept part must be in the netlist, and a model parameter's .model must be in the netlist or a file it includes: otherwise bad_args, never one run passed off as the sweep."],+ pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library). A part without one is left out of the run and named in notSimulated; a sheet with no modelled part refuses no_model.", "Runs on Windows through KiCad's ngspice.dll.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run.", "A swept part must be in the netlist, and a model parameter's .model must be in the netlist or a file it includes: otherwise bad_args, never one run passed off as the sweep."], }, ]; -pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {- Some(match command {- "kicad_export_spice" => export_spice(state, args).unwrap_or_else(|e| e),- "kicad_simulate" => simulate(state, args).unwrap_or_else(|e| e),- _ => return None,- })-}--/// kicad-cli's SPICE netlist of `sch` written to `out`.-fn export(state: &mut State, sch: &Path, out: &Path, format: &str) -> Result<(String, PathBuf), Value> {- let info = state.kicad_info();- let Some(inst) = info.primary() else {- let mut v = fail("not_installed", "KiCad not installed", "The netlist comes from kicad-cli.");- v["alternatives"] = alternatives();- return Err(v);- };- let cli_path = PathBuf::from(&inst.kicad_cli);- let (out_s, sch_s) = (norm(out), norm(sch));- let r = cli::run(&cli_path, &["sch", "export", "netlist", "--format", format, "--output", &out_s, &sch_s], Duration::from_secs(100));- if r.exit_code != 0 || !out.is_file() {- return Err(fail("export_failed", format!("kicad-cli could not export the netlist (rc {}): {}", r.exit_code, r.stderr.trim()), "kicad_run_erc shows what is wrong with the sheet."));- }- let text = std::fs::read_to_string(out).map_err(|e| fail("export_failed", format!("{}: {e}", norm(out)), ""))?;- Ok((text, cli_path))-}--/// The sheet's parts KiCad left out of the netlist for want of a model (#23's notSimulated), and whether the sheet-/// had any part at all. Parts the designer excluded from simulation are left out on purpose and not listed.-fn unmodelled(cli: &Path, sch: &Path, netlist: &str) -> (Vec<String>, usize) {- let Ok(nl) = kicad_core::netlist::load(cli, sch) else { return (Vec::new(), 0) };- let refs: Vec<String> = nl.components.iter()- .filter(|c| !c.reference.starts_with('#') && !c.properties.iter().any(|(k, _)| k == "exclude_from_sim"))- .map(|c| c.reference.clone()).collect();- (spice::not_simulated(&spice::strip_unmodelled(netlist).0, &refs), refs.len())-}--fn sch_arg(args: &Value) -> Result<PathBuf, Value> {- let Some(p) = arg_any(args, &["schPath", "schematic", "filePath"]).map(PathBuf::from) else {- return Err(fail("missing_arg", "schPath is required", "The .kicad_sch to simulate (the root sheet of a project)."));- };- if !p.is_file() {- return Err(fail("file_not_found", format!("{} does not exist", norm(&p)), "Use the desktop's absolute path."));- }- Ok(p)-}--fn export_spice(state: &mut State, args: &Value) -> Result<Value, Value> {- let sch = sch_arg(args)?;- let format = arg_str(args, "format").unwrap_or("spice");- if !["spice", "spicemodel"].contains(&format) {- return Err(fail("bad_args", "format is spice or spicemodel", ""));- }- let out = arg_str(args, "outputPath").map(PathBuf::from).unwrap_or_else(|| sch.with_extension("cir"));- let (text, cli_path) = export(state, &sch, &out, format)?;- let mut v = spice::describe(&text);- v["notSimulated"] = json!(unmodelled(&cli_path, &sch, &text).0);- v["success"] = json!(true);- v["path"] = json!(norm(&out));- v["format"] = json!(format);- v["dialect"] = json!("ngspice"); // KiCad writes ngspice's SPICE (#23)- v["output"] = json!(format!("SPICE netlist of {} written to {}", norm(&sch), norm(&out)));- v["_hint"] = json!("kicad_simulate {schPath, analysis, probes} runs it through KiCad's ngspice.");- Ok(v)-}--fn simulate(state: &mut State, args: &Value) -> Result<Value, Value> {- let sch = sch_arg(args)?;- // the standard's analysis ({type: op|dc|ac|tran|raw, ...}, #23) or SPICE text, one or a list- let given: Vec<Value> = match args.get("analysis") {- Some(Value::Array(a)) => a.clone(),- Some(v) if !v.is_null() => vec![v.clone()],- _ => Vec::new(),- };- let noise = given.iter().any(|g| g["type"] == "noise");- // probes: {net: "OUT"} | {current: "V1"} | "V(/OUT)", as ngspice's vector names; a noise run answers its spectra- let probes: Vec<String> = if noise { Vec::new() } else { args.get("probes").and_then(Value::as_array).map(|a| a.iter().filter_map(spice::probe_vector).collect()).unwrap_or_default() };- let points = args.get("points").and_then(Value::as_u64).unwrap_or(200) as usize;- let tmp = std::env::temp_dir().join(format!("adom-kicad-sim-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)));- let _ = std::fs::create_dir_all(&tmp);- let cir = tmp.join("circuit.cir");- let (text, cli_path) = export(state, &sch, &cir, "spice")?;- // the analyses, a noise analysis's output and reference nets named as the netlist names their nodes- let analyses: Vec<String> = given.iter().map(|g| {- let mut g = g.clone();- if g["type"] == "noise" {- for k in ["output", "reference"] {- if let Some(n) = g[k].as_str().map(|n| spice::resolve_node(&text, n)) {- g[k] = json!(n);- }- }- }- spice::analysis_text(&g)- }).collect::<Result<_, _>>().map_err(|e| { let _ = std::fs::remove_dir_all(&tmp); fail("bad_args", e, "analysis: {type: \"ac\", sweep: \"dec\", points: 20, start: 10, stop: 1e6}, {type: \"tran\", step: 1e-5, stop: 5e-3}, {type: \"dc\", source: \"V1\", start, stop, step}, {type: \"noise\", source: \"V1\", output: \"OUT\", sweep: \"dec\", points: 10, start: 10, stop: 1e6}, {type: \"op\"} or {type: \"raw\", text: \".sens ...\"}.") })?;- let (not_simulated, parts) = unmodelled(&cli_path, &sch, &text);- if parts > 0 && not_simulated.len() == parts {- let _ = std::fs::remove_dir_all(&tmp);- let mut v = fail("no_model", format!("none of the sheet's {parts} part(s) has a simulation model"), "R, C and L simulate from their values, sources come from the Simulation_SPICE library, and an IC needs its model file in Sim.Library (Sim.* fields in the symbol's properties).");- v["notSimulated"] = json!(not_simulated);- return Err(v); } // the parts KiCad wrote as stubs are left out of the run (and named in notSimulated) let lines = spice::prepare(&spice::strip_unmodelled(&text).0, &analyses).map_err(|e| fail("bad_args", e, "analysis: \".tran 10u 5m\", \".ac dec 20 10 1meg\", \".dc V1 0 5 0.1\" or \".op\"."))?;- let dll = kicad_platform::native().ngspice_library(&cli_path);+ let dll = cli_path.parent().unwrap_or(Path::new(".")).join("ngspice.dll"); if !dll.is_file() {- let hint = if kicad_platform::native().capabilities().os == "linux" {- "KiCad's simulator uses the distribution's ngspice library: install it (Debian/Ubuntu: sudo apt install libngspice0; Fedora: sudo dnf install libngspice), then run again."- } else {- "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade)."- };- return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), hint));+ return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade).")); } let cwd = sch.parent().unwrap_or(Path::new(".")).to_path_buf(); // KiCad's XSPICE code models (<kicad>/lib/ngspice/*.cm), loaded the way KiCad's simulator loads them- let cm_dir = kicad_platform::native().ngspice_codemodel_dir(&cli_path);+ let cm_dir = cli_path.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default(); let mut codemodels: Vec<String> = std::fs::read_dir(&cm_dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "cm").unwrap_or(false)).map(|p| norm(&p)).collect(); codemodels.sort();- // a sweep (#23): ngspice has no .step, so one run per value, the value set with alter/altermod and the- // temperature with .temp; each run's signals carry step ("R4 = 300", "T = 85")- let plan = sweep_plan(args, &text, &cwd).inspect_err(|_| { let _ = std::fs::remove_dir_all(&tmp); })?;- let timeout = Duration::from_secs(args.get("timeoutSec").and_then(Value::as_u64).unwrap_or(120).clamp(5, 280));- let started = Instant::now();- let mut runs = Vec::new();- for (label, temp, commands) in &plan {- let run_lines = match temp { Some(t) => spice::with_temp(&lines, *t), None => lines.clone() };- let job = json!({"dll": norm(&dll), "cwd": norm(&cwd), "lines": run_lines, "codemodels": codemodels, "commands": commands});- let run = worker(&job, timeout).inspect_err(|_| { let _ = std::fs::remove_dir_all(&tmp); })?;- if let Some(e) = run["error"].as_str() {- let _ = std::fs::remove_dir_all(&tmp);- let at = label.as_ref().map(|l| format!(" (the run with {l})")).unwrap_or_default();- let mut v = fail("simulation_failed", format!("{e}{at}"), "Read the error: a part without a model, a node with no DC path to ground, or a bad analysis line. kicad_export_spice shows the netlist.");- v["netlist"] = json!(run_lines);- return Err(v);- }- runs.push((label.clone(), run));- }- let _ = std::fs::remove_dir_all(&tmp);- let run = runs[0].1.clone();- let mut v = spice::summarize(&run, &probes, points).map_err(|e| fail("no_such_probe", e, "Probe nets as {net: \"OUT\"} (or V(/OUT)) and source currents as {current: \"V1\"}; available lists this run's vectors."))?;- // the standard's signals (#23): each probe's own x and y, the first, last and extreme points kept- let (mut signals, missing) = spice::signals(&run, &probes, points);- if plan.len() > 1 || plan[0].0.is_some() {- signals.clear();- for (label, r) in &runs {- for mut sg in spice::signals(r, &probes, points).0 {- sg["step"] = json!(label);- signals.push(sg);- }- }- v["runs"] = json!(runs.len());- }- v["signals"] = json!(signals);- // a noise run's integrated noise (#23): {onoise, inoise} in V- if noise {- v["totals"] = spice::noise_totals(&run);- }- v["missing"] = json!(missing);- // a partly modelled sheet runs: these parts are not in the result (#23)- v["notSimulated"] = json!(not_simulated);- let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| sch.parent().unwrap_or(Path::new(".")).join("sim_output"));- let _ = std::fs::create_dir_all(&out_dir);- let stem = sch.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "sim".into());- let csv_path = out_dir.join(format!("{stem}-{}.csv", v["plot"].as_str().unwrap_or("run")));- let _ = std::fs::write(&csv_path, spice::csv(&v));- v["success"] = json!(true);- v["csvPath"] = json!(norm(&csv_path));- v["analysis"] = json!(lines.iter().filter(|l| l.starts_with('.') && !l.eq_ignore_ascii_case(".end") && !l.to_lowercase().starts_with(".title") && !l.to_lowercase().starts_with(".include")).collect::<Vec<_>>());- v["log"] = json!(run["log"].as_array().map(|l| l.iter().rev().take(12).rev().cloned().collect::<Vec<_>>()).unwrap_or_default());- v["seconds"] = json!((started.elapsed().as_secs_f64() * 100.0).round() / 100.0);- // the simulator and its version, so runs from two tools can be compared (#23)- let ng = run["log"].as_array().into_iter().flatten().filter_map(Value::as_str).find_map(|l| l.split("ngspice-").nth(1).and_then(|t| t.split_whitespace().next()).map(String::from));- let kicad = state.kicad_info().primary().map(|p| p.cli_version.clone()).unwrap_or_default();- v["engine"] = json!(format!("ngspice {} (KiCad {})", ng.unwrap_or_else(|| "?".into()), if kicad.is_empty() { "?" } else { kicad.as_str() }));- v["output"] = json!(format!("{} vector(s) of {} over {} point(s)", v["probes"].as_array().map_or(0, |a| a.len()), v["plot"].as_str().unwrap_or(""), v["of"]));- v["_hint"] = json!("probes[].values follow sweep.values (AC: db and phaseDeg). csvPath holds the same points; pull it with ab pull_file. missing lists probes this run did not have; notSimulated names parts left out for want of a model, so the result covers only the rest of the circuit.");- Ok(v)-}--/// The runs a call asks for: one plain run, or one per sweep value and temperature (at most 50), each with its-/// label, its temperature and the ngspice commands that set its value.-fn sweep_plan(args: &Value, netlist: &str, cwd: &Path) -> Result<Vec<(Option<String>, Option<f64>, Vec<String>)>, Value> {- const CAP: usize = 50;- let bad = |e: String| fail("bad_args", e, "sweep: {parameter: \"R4\" (a part's value) or \"Q1.BF\" (a model parameter), start, stop, step, scale?: lin|dec|oct}; temperatures: {start, stop, step} in °C. At most 50 runs in all.");- let range = |o: &Value, what: &str| -> Result<(f64, f64, f64), Value> {- let g = |k: &str| spice::spice_num(&o[k]).ok_or_else(|| bad(format!("{what} needs {k} (a number, or SPICE text like 100p)")));- Ok((g("start")?, g("stop")?, g("step")?))- };- let mut sweep: Vec<(String, String, f64)> = vec![]; // (label, command, value)- if let Some(sw) = args.get("sweep").filter(|v| !v.is_null()) {- let param = sw["parameter"].as_str().filter(|p| !p.trim().is_empty()).ok_or_else(|| bad("sweep needs parameter: \"R4\" or \"Q1.BF\"".into()))?.trim();- let (a, b, st) = range(sw, "sweep")?;- let values = spice::sweep_values(a, b, st, sw["scale"].as_str().unwrap_or("lin"), CAP).map_err(|e| bad(format!("sweep: {e}")))?;- // the .model names in the netlist and the files it includes, for a model parameter- let mut models = spice::model_names(netlist);- for l in netlist.lines() {- let t = l.trim();- if t.to_ascii_lowercase().starts_with(".include") || t.to_ascii_lowercase().starts_with(".lib") {- if let Some(path) = t.split_whitespace().nth(1).map(|p| p.trim_matches(['"', '\''])) {- let p = cwd.join(path);- models.extend(std::fs::read_to_string(&p).map(|x| spice::model_names(&x)).unwrap_or_default());- }- }- }- for x in values {- let cmd = spice::sweep_command(netlist, param, x, &models).map_err(bad)?;- sweep.push((format!("{param} = {}", spice::spice_label(x)), cmd, x));- }- }- // Monte Carlo (#23): every resistor, capacitor, inductor and DC source whose kind has a tolerance is drawn again for- // each run (alter), and the run's step lists the values drawn- if let Some(mc) = args.get("monteCarlo").filter(|v| !v.is_null()) {- if !sweep.is_empty() || args.get("temperatures").is_some_and(|t| !t.is_null()) {- return Err(bad("monteCarlo runs on its own: leave out sweep and temperatures".into()));- }- let runs = mc["runs"].as_u64().filter(|n| (1..=CAP as u64).contains(n)).ok_or_else(|| bad(format!("monteCarlo needs runs, 1 to {CAP}")))? as usize;- let gaussian = match mc["distribution"].as_str().unwrap_or("uniform") { "uniform" => false, "gaussian" | "gauss" => true, d => return Err(bad(format!("distribution is uniform or gaussian, not {d}"))) };- let tol = mc["tolerances"].clone();- let unsupported: Vec<&str> = ["transistor", "digital"].into_iter().filter(|k| tol[*k].as_f64().is_some_and(|t| t > 0.0)).collect();- if !unsupported.is_empty() {- return Err(fail("not_supported", format!("KiCad's simulation cannot draw {} tolerances", unsupported.join(" or ")), "Resistor, capacitor, inductor and dcSource tolerances are drawn (alter); a transistor's model parameters are shared by every part using the model, and KiCad simulates no digital parts here. Leave those out."));- }- let parts = spice::mc_parts(netlist);- let draws = spice::mc_draws(&parts, &tol, runs, gaussian, mc["seed"].as_u64().unwrap_or(1));- if draws.first().is_none_or(|d| d.is_empty()) {- return Err(bad("monteCarlo drew nothing: give tolerances (%) for resistor, capacitor, inductor or dcSource, for parts the netlist has".into()));- }- return Ok(draws.into_iter().map(|d| {- let label = d.iter().map(|(n, x)| format!("{n} = {}", spice::sig6(*x))).collect::<Vec<_>>().join(", ");- (Some(label), None, d.iter().map(|(n, x)| format!("alter {n} = {x:e}")).collect())- }).collect());- }- let mut temps: Vec<f64> = vec![];- if let Some(t) = args.get("temperatures").filter(|v| !v.is_null()) {- let (a, b, st) = range(t, "temperatures")?;- temps = spice::sweep_values(a, b, st, "lin", CAP).map_err(|e| bad(format!("temperatures: {e}")))?;- }- if sweep.len().max(1) * temps.len().max(1) > CAP {- return Err(bad(format!("{} runs ({} values x {} temperatures); at most {CAP}", sweep.len().max(1) * temps.len().max(1), sweep.len().max(1), temps.len().max(1))));- }- let mut plan = Vec::new();- match (sweep.is_empty(), temps.is_empty()) {- (true, true) => plan.push((None, None, vec![])),- (false, true) => plan.extend(sweep.into_iter().map(|(l, c, _)| (Some(l), None, vec![c]))),- (true, false) => plan.extend(temps.iter().map(|t| (Some(format!("T = {}", spice::spice_label(*t))), Some(*t), vec![]))),- (false, false) => {- for (l, c, _) in &sweep {- for t in &temps {- plan.push((Some(format!("{l}, T = {}", spice::spice_label(*t))), Some(*t), vec![c.clone()]));- }- }- }- }- Ok(plan)-}--/// The job in a child of this binary, with a deadline: ngspice keeps global state and can exit its host.-fn worker(job: &Value, timeout: Duration) -> Result<Value, Value> {- let exe = std::env::current_exe().map_err(|e| fail("worker_failed", format!("cannot find this bridge's binary: {e}"), ""))?;- let mut child = std::process::Command::new(exe)- .arg("--ngspice-worker")- .stdin(std::process::Stdio::piped())- .stdout(std::process::Stdio::piped())- .stderr(std::process::Stdio::null())- .spawn()- .map_err(|e| fail("worker_failed", format!("cannot start the simulation worker: {e}"), ""))?;- if let Some(mut si) = child.stdin.take() {- let _ = si.write_all(job.to_string().as_bytes());- }- let stdout = child.stdout.take();- let reader = std::thread::spawn(move || {- let mut s = String::new();- if let Some(mut o) = stdout {- use std::io::Read;- let _ = o.read_to_string(&mut s);- }- s- });- let start = Instant::now();- loop {- match child.try_wait() {- Ok(Some(_)) => break,- Ok(None) if start.elapsed() > timeout => {- let _ = child.kill();- let _ = child.wait();- return Err(fail("simulation_timeout", format!("the simulation ran past {} s and was stopped", timeout.as_secs()), "Shorten the analysis (a smaller stop time or fewer points) or pass timeoutSec."));- }- Ok(None) => std::thread::sleep(Duration::from_millis(100)),- Err(e) => return Err(fail("worker_failed", format!("{e}"), "")),- }- }- let out = reader.join().unwrap_or_default();- let last = out.lines().rev().find(|l| l.trim_start().starts_with('{')).unwrap_or("");- serde_json::from_str(last).map_err(|_| fail("simulation_failed", "the simulation worker ended without an answer (ngspice may have crashed on this circuit)", "kicad_export_spice shows the netlist; check every part has a SPICE model."))-}+ // a sweep (#23): ngspice has no .step, so one run per value, the value set with alter/altermod and the
rust/crates/kicad-bridge/src/verbs_std_more.rs+1−625@@ -1,630 +1,6 @@-//! Standard verbs KiCad answered only partly under other names (tools/equivalents/kicad.json of the EDA Bridge-//! Standard), now under their own names with the standard's arguments and replies: list_library, open_document,-//! open_project, activate_document, render_board_layers, export_bom, routing_drc. Each is a thin shape over KiCad verbs the bridge-//! already has; the ones that open windows call them through verbs::call, so these are never Window verbs themselves-//! (a Window verb calling another through dispatch would wait on the GUI lock it holds).--use std::path::{Path, PathBuf};-use std::time::Duration;--use serde_json::{json, Value};--use crate::catalog::{Mechanism, Verb};-use crate::util::*;--pub static VERBS: &[Verb] = &[- Verb {- name: "kicad_list_library",- summary: "Names of every symbol in a .kicad_sym or every footprint in a .pretty folder, the kind taken from the path (the standard's list_library).",- mechanism: Mechanism::File, risk: "read", timeout_sec: 30,- input: "{\"libraryPath\" | \"filePath\": \"C:/.../Lib.kicad_sym\" | \"C:/.../Lib.pretty\", \"limit\"?: 500}",- example: "kicad_list_library {\"libraryPath\":\"C:/p/MyParts.kicad_sym\"}",- hint: "data.names in file order; kind sch for a symbol library, pcb for a footprint library. kicad_list_symbols and kicad_list_footprints search the registered libraries by name instead.",- related: &["kicad_list_symbols", "kicad_list_footprints", "kicad_sym_info", "kicad_fp_info"],- pitfalls: &["A .pretty is a folder: its footprints are the .kicad_mod files in it."],- },- Verb {- name: "kicad_open_document",- summary: "Open a board, schematic or project by path in KiCad without taking the foreground (the standard's open_document): .kicad_pcb in the PCB editor, .kicad_sch in the Schematic Editor, .kicad_pro in the project manager.",- mechanism: Mechanism::File, risk: "process", timeout_sec: 130,- input: "{\"filePath\" | \"path\": \"C:/.../x.kicad_pcb\" | \".kicad_sch\" | \".kicad_pro\"}",- example: "kicad_open_document {\"filePath\":\"C:/p/board.kicad_pcb\"}",- hint: "data says what was opened (objectKind Board, Schematic or Project) and the reply carries the KiCad verb's own answer. A board opened here is the one the PCB editor's API serves, so the live verbs act on it.",- related: &["kicad_open_board", "kicad_open_schematic", "kicad_launch", "kicad_close"],- pitfalls: &["KiCad holds one project at a time: opening another project's board replaces the open one."],- },- Verb {- name: "kicad_open_project",- summary: "Open a KiCad project (.kicad_pro) in the project manager without taking the foreground (the standard's open_project).",- mechanism: Mechanism::File, risk: "process", timeout_sec: 130,- input: "{\"filePath\" | \"path\": \"C:/.../x.kicad_pro\"}",- example: "kicad_open_project {\"filePath\":\"C:/p/board.kicad_pro\"}",- hint: "Then kicad_open_document opens its board or schematic.",- related: &["kicad_open_document", "kicad_launch"],- pitfalls: &[],- },- Verb {- name: "kicad_activate_document",- summary: "Make a board or schematic the current one, in the background (the standard's activate_document): KiCad's verbs take the file's path, and a board opened here is the one the PCB editor's API serves.",- mechanism: Mechanism::File, risk: "process", timeout_sec: 130,- input: "{\"path\" | \"document\" | \"filePath\": \"C:/.../x.kicad_pcb\" | \".kicad_sch\"}",- example: "kicad_activate_document {\"path\":\"C:/p/board.kicad_pcb\"}",- hint: "An open document is brought up, not reloaded; one that is not open is opened.",- related: &["kicad_open_document", "kicad_open_board"],- pitfalls: &["KiCad holds one board and one schematic per project: activating another project's board replaces the open one."],- },- Verb {- name: "kicad_render_board_layers",- summary: "Render chosen board layers to a PNG or SVG through kicad-cli without opening a window (the standard's render_board_layers): a preset view (copper, silkscreen, solder mask, paste, outline, drill, assembly, fabrication, either side) or a list of layers.",- mechanism: Mechanism::Cli, risk: "write", timeout_sec: 240,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"preset\"?: \"all\"|\"top_copper\"|\"bottom_copper\"|\"top_silkscreen\"|\"bottom_silkscreen\"|\"top_soldermask\"|\"bottom_soldermask\"|\"top_paste\"|\"bottom_paste\"|\"outline\"|\"drill\"|\"assembly_top\"|\"assembly_bottom\"|\"fabrication\", \"layers\"?: [\"F.Cu\", ...] (overrides preset), \"format\"?: \"png\"|\"svg\", \"dpi\"?: 300, \"mirror\"?: bool, \"outputPath\"?: \"C:/out/top.png\", \"listPresets\"?: bool}",- example: "kicad_render_board_layers {\"filePath\":\"C:/p/b.kicad_pcb\",\"preset\":\"top_copper\"}",- hint: "The picture is cropped to the board. Bottom presets are mirrored, so they read as seen from below (mirror overrides). drill draws the holes at their real size over the outline. kicad_render_board is the 3D view instead.",- related: &["kicad_export_svg", "kicad_render_board", "kicad_export_gerber"],- pitfalls: &["Reads the saved board file: save the open board first."],- },- Verb {- name: "kicad_export_bom",- summary: "Export the project's bill of materials as a CSV through kicad-cli from its root schematic (the standard's export_bom).",- mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,- input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\": \"C:/.../x.kicad_sch\", \"outputDir\"?: \"C:/...\"}",- example: "kicad_export_bom {\"projectPath\":\"C:/p/board.kicad_pro\"}",- hint: "files lists what was written in outputDir. kicad_bom groups by part number and writes JLCPCB, Mouser and DigiKey files instead.",- related: &["kicad_bom", "kicad_export_bom_csv", "kicad_export_pos"],- pitfalls: &["Reads the saved schematic: save it in KiCad first."],- },- Verb {- name: "kicad_routing_drc",- summary: "KiCad's DRC on the board as the PCB editor holds it (unsaved edits included; the file when no editor serves it), with the project's rules and zones refilled: one clean or not-clean answer with every violation's place (the standard's routing_drc).",- mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"source\"?: \"auto\"|\"live\"|\"file\", \"full\"?: true}",- example: "kicad_routing_drc {\"filePath\":\"C:/p/b.kicad_pcb\"}",- hint: "clean is true with no violations and nothing unrouted. violations[] are {rule (KiCad's check), severity, description, at {x, y} mm in KiCad's frame}; unconnected counts the ratsnest. KiCad's DRC is always a full one: full is accepted and changes nothing.",- related: &["kicad_run_drc", "kicad_routing_validate", "kicad_routing_state"],- pitfalls: &["Warnings count as violations here, as KiCad lists them; severity tells them apart."],- },- Verb {- name: "kicad_set_track_width",- summary: "Set the width of the EXISTING tracks and arcs on nets or a net class, optionally on one layer (the standard's set_track_width): widen power nets after routing, or thin a net that fails clearance. Net-class rules only steer new routing.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 300,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"VBUS\"] | \"netClass\"?: \"Power\", \"widthMm\": 0.5, \"layer\"?: \"top\"|\"bottom\"|\"mid 1\"|\"F.Cu\", \"source\"?: \"auto\"|\"live\"|\"file\", \"dryRun\"?: bool}",- example: "kicad_set_track_width {\"filePath\":\"C:/p/b.kicad_pcb\",\"nets\":[\"VBUS\"],\"widthMm\":0.6}",- hint: "byNet counts the tracks each net has (changed is how many took a new width); drcAfter is KiCad's DRC on the board afterwards {errors, unconnected}. On a board open in the PCB editor it is one Undo step through kicad_modify_track (track ids, ends and nets kept); a closed board is written with a .adom-bak.",- related: &["kicad_modify_track", "kicad_netclasses", "kicad_routing_drc", "kicad_current_capacity"],- pitfalls: &["A wider track can break clearance: read drcAfter, and narrow it again or reroute where it does.", "netClass uses the project's class patterns (.kicad_pro beside the board)."],- },- Verb {- name: "kicad_footprint_primitives",- summary: "Geometry of one PLACED part on the board (the standard's footprint_primitives): its graphic lines and arcs, pads (shape, size, hole, net, solder and paste mask expansion in um) and texts, in mils in KiCad's board frame with each item's layer; other kinds are counted.",- mechanism: Mechanism::File, risk: "read", timeout_sec: 60,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"designator\": \"U1\", \"source\"?: \"auto\"|\"live\"|\"file\"}",- example: "kicad_footprint_primitives {\"filePath\":\"C:/p/b.kicad_pcb\",\"designator\":\"U1\"}",- hint: "data.primitives[] are {kind: track|arc|pad|text, layerId (KiCad's layer name), ...} placed on the board (the part's position and rotation applied); otherPrimitives counts polygons, circles, rectangles and models. Mask expansions are the pad's own, else the footprint's; null means the board's default.",- related: &["kicad_footprint_graphics", "kicad_component_pins", "kicad_routing_state"],- pitfalls: &["mils, y down (KiCad's frame): kicad_routing_state {model:\"standard\"} has the board in mm, y up."],- },- Verb {- name: "kicad_rules_by_kind",- summary: "Every design rule the board answers to, grouped by kind (Clearance, Width, RoutingVias, HoleSize, ...) and sorted by priority (1 wins), from the project's custom rules (.kicad_dru), net classes and Board Setup minimums (the standard's rules_by_kind).",- mechanism: Mechanism::File, risk: "read", timeout_sec: 30,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\" | \"projectPath\": \"C:/.../x.kicad_pro\", \"limit\"?: 2000}",- example: "kicad_rules_by_kind {\"projectPath\":\"C:/p/board.kicad_pro\"}",- hint: "rulesByKind.{Kind}[] are {name, kind, constraint (KiCad's), enabled, priority, scope1 (the condition, NetClass == '...' or All), scope2, min/opt/max, source: custom|netclass|board}. KiCad's precedence orders them: a later custom rule over an earlier one, custom rules over net classes, both over Board Setup. kicad_custom_rules and kicad_netclasses edit them.",- related: &["kicad_board_rules", "kicad_netclasses", "kicad_custom_rules", "kicad_run_drc"],- pitfalls: &["Reads the saved project files: rules changed in an open Board Setup dialog count once saved."],- },- Verb {- name: "kicad_document_info",- summary: "The active KiCad document (the topmost KiCad window): its name, kind (board, schematic, library, project, model), whether it has unsaved changes, where it is saved, and its project (the standard's document_info). Read only; opens nothing.",- mechanism: Mechanism::File, risk: "read", timeout_sec: 30,- input: "{}",- example: "kicad_document_info {}",- hint: "modified is the leading * KiCad puts in the title. path comes from the editor's own command line (via Adom Bridge), or the project folder for an editor opened inside the project manager, else KiCad's recent-file list (pathFrom says which). version is null: KiCad keeps no versions. windows[] lists every KiCad window, topmost first.",- related: &["kicad_state", "kicad_activate_document", "kicad_open_document"],- pitfalls: &["With no KiCad window open it answers no_document.", "A new, never-saved sheet is untitled with saved false and path null."],- },-];--pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {- Some(match command {- "kicad_list_library" => list_library(args),- "kicad_open_document" => open_doc(state, args, "open_document"),- "kicad_open_project" => open_doc(state, args, "open_project"),- "kicad_activate_document" => open_doc(state, args, "activate_document"),- "kicad_render_board_layers" => render_layers(state, args),- "kicad_export_bom" => export_bom(state, args),- "kicad_routing_drc" => routing_drc(state, args),- "kicad_set_track_width" => set_track_width(state, args),- "kicad_footprint_primitives" => footprint_primitives(state, args),- "kicad_rules_by_kind" => rules_by_kind(args),- "kicad_document_info" => document_info(),- _ => return None,- })-}--fn ext(p: &Path) -> String {- p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()-}--fn list_library(args: &Value) -> Value {- let Some(path) = arg_any(args, &["libraryPath", "filePath", "path"]).map(|p| PathBuf::from(p.trim_end_matches(['/', '\\']))) else {- return fail("missing_arg", "libraryPath is required", "A .kicad_sym file or a .pretty folder.");- };- let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;- let (kind, mut names): (&str, Vec<String>) = match ext(&path).as_str() {- "kicad_sym" => {- let text = match std::fs::read_to_string(&path) {- Ok(t) => t,- Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .kicad_sym's absolute path."),- };- match kicad_core::sxedit::parse(&text) {- Ok(root) => ("sch", root.kids_tagged("symbol").filter_map(|s| s.atom(1).map(str::to_string)).collect()),- Err(e) => return fail("library_unparsable", format!("{} does not parse: {e}", norm(&path)), "Open and save it in KiCad's Symbol Editor to repair it."),- }- }- "pretty" => match std::fs::read_dir(&path) {- Ok(rd) => ("pcb", rd.flatten().map(|e| e.path()).filter(|p| ext(p) == "kicad_mod").filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())).collect()),- Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .pretty folder's absolute path."),- },- _ => return fail("bad_args", format!("{} is not a .kicad_sym or a .pretty folder", norm(&path)), "Symbol libraries are .kicad_sym files; footprint libraries are .pretty folders."),- };- if kind == "pcb" {- names.sort();- }- let count = names.len();- names.truncate(limit);- json!({"success": true, "kind": kind, "path": norm(&path), "data": {"kind": kind, "count": count, "names": names}, "truncated": count > limit})-}--fn open_doc(state: &mut State, args: &Value, which: &str) -> Value {- let Some(path) = arg_any(args, &["filePath", "path", "document", "name"]).map(PathBuf::from) else {- return fail("missing_arg", "filePath (or path) is required", "The absolute path of a .kicad_pcb, .kicad_sch or .kicad_pro.");- };- if !path.is_file() {- return fail("file_not_found", format!("not found: {}", norm(&path)), "Use the desktop's absolute path.");- }- let p = norm(&path);- let (verb, a, object, kind) = match ext(&path).as_str() {- "kicad_pcb" if which != "open_project" => ("kicad_open_board", json!({"filePath": p, "foreground": false}), "Board", "pcb"),- "kicad_sch" if which != "open_project" => ("kicad_open_schematic", json!({"filePath": p, "foreground": false}), "Schematic", "sch"),- "kicad_pro" if which != "activate_document" => ("kicad_launch", json!({"project": p}), "Project", "project"),- e => {- let want = match which { "open_project" => "a .kicad_pro", "activate_document" => "a .kicad_pcb or .kicad_sch", _ => "a .kicad_pcb, .kicad_sch or .kicad_pro" };- return fail("bad_args", format!("{which} takes {want}, not .{e}"), "Library files open in the Symbol and Footprint Editors (kicad_open_symbol_editor, kicad_open_footprint_editor).");- }- };- let mut out = crate::verbs::call(state, verb, &a);- if out["success"] == json!(true) {- out["data"] = json!({"path": p, "objectKind": object, "kind": kind, "shown": true, "lane": verb});- out["windowEtiquette"] = json!("background");- }- out-}--/// (preset, layers, mirrored, drill marks at their real size, what it shows)-const PRESETS: &[(&str, &[&str], bool, bool, &str)] = &[- ("all", &["F.Cu", "B.Cu", "F.Silkscreen", "B.Silkscreen", "F.Mask", "B.Mask", "Edge.Cuts"], false, false, "copper, silkscreen and mask of both sides with the outline"),- ("top_copper", &["F.Cu", "Edge.Cuts"], false, false, "top copper and the outline"),- ("bottom_copper", &["B.Cu", "Edge.Cuts"], true, false, "bottom copper, seen from below"),- ("top_silkscreen", &["F.Silkscreen", "Edge.Cuts"], false, false, "top silkscreen and the outline"),- ("bottom_silkscreen", &["B.Silkscreen", "Edge.Cuts"], true, false, "bottom silkscreen, seen from below"),- ("top_soldermask", &["F.Mask", "Edge.Cuts"], false, false, "top solder mask openings"),- ("bottom_soldermask", &["B.Mask", "Edge.Cuts"], true, false, "bottom solder mask openings, seen from below"),- ("top_paste", &["F.Paste", "Edge.Cuts"], false, false, "top paste (stencil) openings"),- ("bottom_paste", &["B.Paste", "Edge.Cuts"], true, false, "bottom paste openings, seen from below"),- ("outline", &["Edge.Cuts"], false, false, "the board outline"),- ("drill", &["Edge.Cuts"], false, true, "every hole at its real size over the outline"),- ("assembly_top", &["F.Fab", "Edge.Cuts"], false, false, "top fabrication layer (part outlines and references)"),- ("assembly_bottom", &["B.Fab", "Edge.Cuts"], true, false, "bottom fabrication layer, seen from below"),- ("fabrication", &["Edge.Cuts", "User.Drawings", "User.Comments"], false, true, "outline, drawings and holes for the fab"),-];--fn render_layers(state: &mut State, args: &Value) -> Value {- let presets: serde_json::Map<String, Value> = PRESETS.iter().map(|p| (p.0.to_string(), json!(p.4))).collect();- if args.get("listPresets") == Some(&json!(true)) {- return json!({"success": true, "presets": presets});- }- let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {- return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");- };- if !board.is_file() {- return fail("file_not_found", format!("board not found: {}", norm(&board)), "Use the desktop's absolute path.");- }- let preset_name = arg_str(args, "preset").unwrap_or("all");- let Some(preset) = PRESETS.iter().find(|p| p.0 == preset_name) else {- let mut v = fail("bad_args", format!("no preset {preset_name}"), "listPresets:true lists them.");- v["presets"] = json!(presets);- return v;- };- let given: Vec<String> = match args.get("layers") {- Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),- Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),- _ => Vec::new(),- };- let custom = !given.is_empty();- let layers: Vec<String> = if custom { given } else { preset.1.iter().map(|s| s.to_string()).collect() };- let mirror = args.get("mirror").and_then(Value::as_bool).unwrap_or(!custom && preset.2);- let format = arg_str(args, "format").unwrap_or("png").to_ascii_lowercase();- if format != "png" && format != "svg" {- return fail("bad_args", format!("format must be png or svg, not {format}"), "png is the default.");- }- let dpi = args.get("dpi").and_then(Value::as_f64).unwrap_or(300.0).clamp(30.0, 2400.0);- let info = state.kicad_info();- let Some(cli) = info.primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()) else {- return fail("not_installed", "kicad-cli was not found", "Install KiCad 10 (kicad_upgrade).");- };- let stem = board.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "board".into());- let label = if custom { "custom".to_string() } else { preset_name.to_string() };- let out = arg_any(args, &["outputPath", "output"]).map(PathBuf::from)- .unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join("layers_output").join(format!("{stem}-{label}.{format}")));- if let Some(d) = out.parent() {- if let Err(e) = std::fs::create_dir_all(d) {- return fail("output_dir", format!("cannot create {}: {e}", norm(d)), "Pass an outputPath the desktop user can write.");- }- }- let svg = if format == "svg" { out.clone() } else { out.with_extension("svg") };- let (svg_s, board_s, layer_arg) = (norm(&svg), norm(&board), layers.join(","));- let mut argv = vec!["pcb", "export", "svg", "--mode-single", "--page-size-mode", "2", "--exclude-drawing-sheet", "--layers", layer_arg.as_str(), "--output", svg_s.as_str()];- if mirror {- argv.push("--mirror");- }- if !custom && preset.3 {- argv.extend(["--drill-shape-opt", "2"]);- }- argv.push(board_s.as_str());- let r = kicad_core::cli::run(&cli, &argv, Duration::from_secs(200));- if r.exit_code != 0 || !svg.is_file() {- let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("kicad-cli could not plot the layers: {}", r.stderr.trim()), "A layer name KiCad does not know is the usual cause: F.Cu, In1.Cu, B.Cu, F.Silkscreen, F.Mask, F.Paste, F.Fab, Edge.Cuts, User.Drawings ...");- v["layers"] = json!(layers);- return v;- }- let mut reply = json!({"success": true, "format": format, "preset": label, "layers": layers, "mirrored": mirror, "board": board_s, "presets": presets, "cliElapsedMs": r.elapsed_ms});- if format == "svg" {- reply["path"] = json!(svg_s);- return reply;- }- let bytes = match std::fs::read(&svg) {- Ok(b) => b,- Err(e) => return fail("read_failed", format!("cannot read {svg_s}: {e}"), "Try again; the plot was written a moment ago."),- };- // the plot's size in mm, then the width that gives the dpi asked for- let probe = match kicad_core::sch_render::rasterize(&bytes, 64) {- Ok(p) => p,- Err(e) => return fail("render_failed", e, "format:\"svg\" writes the plot without rasterising it."),- };- let width = ((probe.width_mm / 25.4 * dpi).round() as u32).clamp(16, 16384);- let raster = match kicad_core::sch_render::rasterize(&bytes, width) {- Ok(p) => p,- Err(e) => return fail("render_failed", e, "Pass a lower dpi, or format:\"svg\"."),- };- if let Err(e) = kicad_core::sch_render::save_png(&raster.pixmap, &out) {- return fail("write_failed", e, "Pass an outputPath the desktop user can write.");- }- let _ = std::fs::remove_file(&svg);- reply["path"] = json!(norm(&out));- reply["widthPx"] = json!(raster.pixmap.width());- reply["heightPx"] = json!(raster.pixmap.height());- reply["dpi"] = json!(dpi);- reply-}--fn export_bom(state: &mut State, args: &Value) -> Value {- let sch = match (arg_str(args, "projectPath"), arg_any(args, &["filePath", "schematicPath"])) {- (_, Some(f)) => PathBuf::from(f),- (Some(p), None) => PathBuf::from(p).with_extension("kicad_sch"),- _ => return fail("missing_arg", "projectPath or filePath (the root .kicad_sch) is required", "The project; its root sheet sits beside the .kicad_pro."),- };- let started = std::time::Instant::now();- let mut a = json!({"filePath": norm(&sch)});- if let Some(d) = arg_str(args, "outputDir") {- a["outputDir"] = json!(d);- }- let mut out = crate::verbs::call(state, "kicad_export_bom_csv", &a);- if out["success"] == json!(true) {- out["files"] = out.pointer("/data/files").cloned().unwrap_or(json!([]));- out["outputDir"] = out.pointer("/data/output_path").and_then(Value::as_str).map(|p| json!(norm(Path::new(p).parent().unwrap_or(Path::new(p))))).unwrap_or(Value::Null);- out["surface"] = json!("bom");- out["outjob"] = Value::Null;- out["dialogs"] = json!([]);- out["elapsedSec"] = json!(started.elapsed().as_secs_f64());- }- out-}--fn routing_drc(state: &mut State, args: &Value) -> Value {- let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {- return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");- };- let r = match crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": arg_str(args, "source").unwrap_or("auto")})) {- Ok(r) => r,- Err(e) => return e,- };- let cli = state.kicad_info().primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());- let rep = match drc_of(cli.as_deref(), &board, &r.text) {- Ok(v) => v,- Err(e) => return e,- };- let at = |v: &Value| v.pointer("/items/0/pos").map(|p| json!({"x": p.get("x"), "y": p.get("y")})).unwrap_or(Value::Null);- let violations: Vec<Value> = rep["violations"].as_array().cloned().unwrap_or_default().iter()- .map(|v| json!({"rule": v.get("type"), "severity": v.get("severity"), "description": v.get("description"), "at": at(v)}))- .collect();- let unconnected = rep["unconnected"].as_u64().unwrap_or(0);- let errors = violations.iter().filter(|v| v["severity"] == json!("error")).count();- json!({- "success": true, "clean": violations.is_empty() && unconnected == 0, "errors": errors, "warnings": violations.len() - errors,- "unconnected": unconnected, "violations": violations, "full": true, "boardSource": if r.live { "live-editor" } else { "file" }, "board": norm(&board),- "projectRulesCopied": rep.get("projectRulesCopied"),- "_hint": "Positions are mm in KiCad's frame (y down). kicad_routing_state {model:\"standard\"} gives the same board in the standard's y-up frame.",- })-}--/// A KiCad window title: (name, kind, unsaved, sub-sheet), or None for a window that holds no document.-/// "*rp2040 — PCB Editor", "untitled [Unsaved] — Schematic Editor", "board [/power/] — Schematic Editor",-/// "rp2040 — KiCad 10.0" (the project manager).-fn parse_title(title: &str) -> Option<(String, &'static str, bool, Option<String>)> {- let (left, right) = title.rsplit_once(" — ").or_else(|| title.rsplit_once(" - "))?;- let kind = match right.trim() {- "PCB Editor" => "board",- "Schematic Editor" => "schematic",- "Symbol Editor" | "Footprint Editor" => "library",- r if r.starts_with("KiCad") => "project",- r if r.contains("3D Viewer") => "model",- _ => return None,- };- let modified = left.starts_with('*');- let mut name = left.trim_start_matches('*').trim().to_string();- let mut sub = None;- if let Some(i) = name.find(" [") {- let tag = name[i + 2..].trim_end_matches(']').to_string();- if tag != "Unsaved" {- sub = Some(tag);- }- name.truncate(i);- }- Some((name, kind, modified, sub))-}--/// The document a KiCad process was started on: the .kicad_pcb, .kicad_sch or .kicad_pro in its command line.-fn doc_arg(cmd: &str) -> Option<String> {- let exts = [".kicad_pcb", ".kicad_sch", ".kicad_pro"];- let quoted: Vec<&str> = cmd.split('"').skip(1).step_by(2).collect();- let pool: Vec<&str> = if quoted.is_empty() { cmd.split_whitespace().collect() } else { quoted };- pool.into_iter().rev().find(|t| exts.iter().any(|e| t.to_ascii_lowercase().ends_with(e))).map(|t| t.replace('\\', "/"))-}--fn document_info() -> Value {- use kicad_platform::native;- const KICAD: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad", "pcbnew", "eeschema"];- let wins: Vec<_> = native().list_windows().unwrap_or_default().into_iter().filter(|w| KICAD.contains(&w.exe.as_str()) && w.owner == 0).collect();- let docs: Vec<(kicad_platform::WindowInfo, (String, &'static str, bool, Option<String>))> = wins.iter().filter_map(|w| parse_title(&w.title).map(|t| (w.clone(), t))).collect();- // the topmost editor; the project manager only when no editor is open- let Some((win, (name, kind, modified, sub))) = docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()).cloned() else {- return fail("no_document", "no KiCad document is open", "kicad_open_document opens one; kicad_state lists KiCad's windows.");- };- let ext = match kind { "board" => "kicad_pcb", "schematic" => "kicad_sch", "project" => "kicad_pro", _ => "" }; let untitled = name == "untitled"; // the editor's command line (Adom Bridge's process_list), or the project manager's for an editor it hosts let cmd_of = |pid: u32| -> Option<String> {- // Linux: the editor's arguments are in /proc, no ab round trip (each one quoted, for doc_arg).- if let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) {- let args: Vec<String> = raw.split(|b| *b == 0).filter(|a| !a.is_empty()).map(|a| format!("\"{}\"", String::from_utf8_lossy(a))).collect();- if !args.is_empty() {- return Some(args.join(" "));- }- } let r = kicad_core::ab::call("process_list", json!({}), Duration::from_secs(15)).ok()?; let v = kicad_core::ab::unwrap_payload(&r.raw);- let ps = v.get("processes").or_else(|| v.get("data")).and_then(Value::as_array)?.clone();- ps.iter().find(|p| p["pid"].as_u64() == Some(pid as u64)).and_then(|p| p["commandLine"].as_str().map(str::to_string))- };- let mut path: Option<String> = None;- let mut path_from = "none";- if !untitled && !ext.is_empty() {- if let Some(arg) = cmd_of(win.pid).and_then(|c| doc_arg(&c)) {- let p = PathBuf::from(&arg);- let candidate = p.with_file_name(format!("{name}.{ext}"));- if p.extension().map_or(false, |e| e == ext) && p.file_stem().map_or(false, |s| s.to_string_lossy() == name) {- path = Some(norm(&p));- path_from = "commandLine";- } else if candidate.exists() || p.extension().map_or(false, |e| e == "kicad_pro") {- path = Some(norm(&candidate));- path_from = "projectFolder";- }- }- if path.is_none() {- // KiCad's recent-file list, newest first- let file = match kind { "board" => "pcbnew.json", "schematic" => "eeschema.json", _ => "kicad.json" };- let hist = kicad_core::detect::detect().primary().and_then(|i| kicad_core::detect::config_dir(&i.version))- .and_then(|d| std::fs::read_to_string(d.join(file)).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok())- .and_then(|v| v.pointer("/system/file_history").and_then(Value::as_array).cloned()).unwrap_or_default();- if let Some(h) = hist.iter().filter_map(Value::as_str).find(|h| Path::new(&h.replace('\\', "/")).file_stem().map_or(false, |s| s.to_string_lossy() == name)) {- path = Some(h.replace('\\', "/"));- path_from = "recentFiles";- }- }- }- let project = path.as_ref().map(|p| PathBuf::from(p)).and_then(|p| {- let pro = p.with_extension("kicad_pro");- if pro.exists() { Some(norm(&pro)) } else { std::fs::read_dir(p.parent()?).ok()?.flatten().map(|e| e.path()).find(|x| ext_of(x) == "kicad_pro").map(|x| norm(&x)) }- });- json!({- "success": true, "name": name, "kind": kind, "modified": modified,- "saved": path.as_ref().map_or(false, |p| Path::new(p).exists()), "path": path, "pathFrom": path_from,- "version": Value::Null, "project": project, "subSheet": sub,- "window": {"title": win.title, "exe": win.exe, "pid": win.pid},- "windows": wins.iter().map(|w| json!({"title": w.title, "exe": w.exe})).collect::<Vec<_>>(),- })-}--fn ext_of(p: &Path) -> String {- ext(p)-}--fn rules_by_kind(args: &Value) -> Value {- let Some(pro) = arg_any(args, &["projectPath", "filePath", "boardPath"]).map(|p| PathBuf::from(p).with_extension("kicad_pro")) else {- return fail("missing_arg", "projectPath or filePath is required", "The project, or its board: the .kicad_pro and .kicad_dru sit beside it.");- };- let pv: Value = match std::fs::read_to_string(&pro) {- Ok(t) => serde_json::from_str(&t).unwrap_or(Value::Null),- Err(e) => return fail("project_not_found", format!("cannot read {}: {e}", norm(&pro)), "Pass the board or project path; its .kicad_pro must exist."),- };- let dru = std::fs::read_to_string(pro.with_extension("kicad_dru")).ok();- let mut v = kicad_core::project_rules::rules_by_kind(&pv, dru.as_deref());- let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(2000) as usize;- if let Some(m) = v["rulesByKind"].as_object_mut() {- for list in m.values_mut() {- if let Some(a) = list.as_array_mut() {- a.truncate(limit);- }- }- }- v["success"] = json!(true);- v["board"] = json!(norm(&pro.with_extension("kicad_pcb")));- v["customRules"] = json!(dru.is_some());- v-}--fn footprint_primitives(state: &mut State, args: &Value) -> Value {- let Some(d) = arg_any(args, &["designator", "ref", "reference"]) else {- return fail("missing_arg", "designator is required", "A part on the board, e.g. U1.");- };- let r = match crate::verbs_standard::read(state, args) {- Ok(r) => r,- Err(e) => return e,- };- match kicad_core::board_edit::footprint_primitives(&r.text, d) {- Ok(data) => json!({"success": true, "data": data, "boardSource": if r.live { "live-editor" } else { "file" }, "file": r.file}),- Err(e) => fail(&e.code, e.message.clone(), "kicad_list_designators lists the parts on the board."),- }-}--fn set_track_width(state: &mut State, args: &Value) -> Value {- use kicad_core::board_edit;- let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {- return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");- };- let Some(width) = args.get("widthMm").or_else(|| args.get("width")).and_then(Value::as_f64) else {- return fail("missing_arg", "widthMm is required", "The new track width in mm, e.g. 0.5.");- };- let layer = arg_str(args, "layer").map(board_edit::track_layer);- let source = arg_str(args, "source").unwrap_or("auto");- let r = match crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": source})) {- Ok(r) => r,- Err(e) => return e,- };- let mut nets: Vec<String> = match args.get("nets") {- Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),- Some(Value::String(s)) => vec![s.clone()],- _ => Vec::new(),- };- if let Some(class) = arg_str(args, "netClass") {- let pro: Value = std::fs::read_to_string(board.with_extension("kicad_pro")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);- let all = kicad_core::sxedit::parse(&r.text).map(|root| board_edit::board_net_names(&root)).unwrap_or_default();- nets.extend(all.into_iter().filter(|n| !n.is_empty() && kicad_core::project_rules::class_for_net(&pro, n)["name"] == json!(class)));- }- nets.sort();- nets.dedup();- if nets.is_empty() {- return fail("missing_arg", "nets or netClass is required, and must name nets on the board", "kicad_list_nets lists the nets; kicad_netclasses the classes and their nets.");- }- let hits = match board_edit::tracks_on(&r.text, &nets, layer.as_deref()) {- Ok(h) => h,- Err(e) => return crate::util::fail(&e.code, e.message.clone(), "The board does not parse; open and save it in KiCad."),- };- let mut by_net = serde_json::Map::new();- for (_, n, _) in &hits {- let c = by_net.get(n).and_then(Value::as_u64).unwrap_or(0) + 1;- by_net.insert(n.clone(), json!(c));- }- let ids: Vec<String> = hits.iter().filter(|(_, _, w)| (w - width).abs() >= 1e-9).map(|(i, _, _)| i.clone()).collect();- let dry = args.get("dryRun") == Some(&json!(true));- let mut out = if r.live {- if ids.is_empty() || dry {- json!({"success": true, "source": "live-editor", "mutated": false, "dryRun": dry, "wouldChange": ids.len()})- } else {- let rs = crate::verbs::call(state, "kicad_routing_state", &json!({"filePath": norm(&board)}));- let m = crate::verbs::call(state, "kicad_modify_track", &json!({"filePath": norm(&board), "expectedRevision": rs.get("revision"), "itemIds": ids, "width": width}));- if m["success"] != json!(true) {- return m;- }- let mut o = json!({"success": true, "source": "live-editor", "mutated": true, "undoSteps": 1});- for k in ["revision", "verified", "drc"] {- if let Some(v) = m.get(k) {- o[k] = v.clone();- }- }- o- }- } else {- crate::verbs_board::file_edit(args, &|t| board_edit::set_track_width(t, &nets, layer.as_deref(), width))- };- if out["success"] != json!(true) {- return out;- }- out["changed"] = json!(if dry || out["mutated"] != json!(true) { 0 } else { ids.len() });- out["byNet"] = json!(by_net);- out["nets"] = json!(nets);- out["widthMm"] = json!(width);- out["layer"] = json!(layer);- // KiCad's DRC on the board as it is now- let after = crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": if r.live { "live" } else { "file" }})).ok();- let cli = state.kicad_info().primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());- if let Some(a) = after {- match drc_of(cli.as_deref(), &board, &a.text) {- Ok(d) => out["drcAfter"] = json!({"errors": d["errors"], "unconnected": d["unconnected"], "clean": d["clean"]}),- Err(e) => out["drcAfter"] = json!({"error": e["error"]}),- }- }- out-}--#[cfg(feature = "ipc")]-fn drc_of(cli: Option<&Path>, board: &Path, text: &str) -> Result<Value, Value> {- kicad_core::ipc::drc_snapshot(cli, board, text).map_err(|e| kicad_core::ipc::error_response(&e))-}--#[cfg(not(feature = "ipc"))]-fn drc_of(_cli: Option<&Path>, _board: &Path, _text: &str) -> Result<Value, Value> {- Err(fail("ipc_feature_off", "This build has the ipc feature off", "kicad_run_drc checks the saved file."))-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn list_library_reads_symbols_and_footprints_by_path() {- let d = std::env::temp_dir().join(format!("kb-listlib-{}", std::process::id()));- let pretty = d.join("Mine.pretty");- std::fs::create_dir_all(&pretty).unwrap();- std::fs::write(d.join("Mine.kicad_sym"), "(kicad_symbol_lib (version 20241209) (symbol \"R\" (symbol \"R_0_1\")) (symbol \"LED\"))").unwrap();- std::fs::write(pretty.join("SOT-23.kicad_mod"), "(footprint \"SOT-23\")").unwrap();- std::fs::write(pretty.join("R_0603.kicad_mod"), "(footprint \"R_0603\")").unwrap();- let s = list_library(&json!({"libraryPath": norm(&d.join("Mine.kicad_sym"))}));- assert_eq!((s["kind"].as_str(), s["data"]["names"].clone()), (Some("sch"), json!(["R", "LED"])), "{s}");- let f = list_library(&json!({"filePath": format!("{}/", norm(&pretty)), "limit": 1}));- assert_eq!((f["kind"].as_str(), f["data"]["count"].as_u64(), f["data"]["names"].clone()), (Some("pcb"), Some(2), json!(["R_0603"])), "{f}");- assert_eq!(list_library(&json!({"libraryPath": "C:/x/board.kicad_pcb"}))["errorCode"], "bad_args");- let _ = std::fs::remove_dir_all(&d);- }-- #[test]- fn window_titles_and_command_lines_name_the_document() {- assert_eq!(parse_title("*esc — PCB Editor"), Some(("esc".into(), "board", true, None)));- assert_eq!(parse_title("rp2040-breakout — Schematic Editor"), Some(("rp2040-breakout".into(), "schematic", false, None)));- assert_eq!(parse_title("untitled [Unsaved] — Schematic Editor"), Some(("untitled".into(), "schematic", false, None)));- assert_eq!(parse_title("board [/power/] — Schematic Editor"), Some(("board".into(), "schematic", false, Some("/power/".into()))));- assert_eq!(parse_title("rp2040-breakout — KiCad 10.0").map(|t| t.1), Some("project"));- assert_eq!(parse_title("Load PCB"), None);- assert_eq!(doc_arg("C:/K/bin/pcbnew.exe C:/p/b.kicad_pcb").as_deref(), Some("C:/p/b.kicad_pcb"));- assert_eq!(doc_arg("\"C:\\Program Files\\K\\eeschema.exe\" \"C:\\My Boards\\a.kicad_sch\"").as_deref(), Some("C:/My Boards/a.kicad_sch"));- assert_eq!(doc_arg("C:/K/bin/kicad.exe"), None);- }-- #[test]- fn every_preset_names_layers_and_bottom_ones_are_mirrored() {- assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty()));- assert!(PRESETS.iter().filter(|p| p.0.starts_with("bottom") || p.0 == "assembly_bottom").all(|p| p.2));- assert_eq!(PRESETS.len(), 14, "the standard's fourteen presets");- }-}+ let ps = v.get("processes").or_else(|| v.get("data")).and_then(Value::as_array)?.clone();
rust/crates/kicad-bridge/src/verbs_windows.rs+10−3835@@ -1,3871 +1,46 @@-//! Verb group "windows": the KiCad window verbs, phase 3 of docs/rust-port-plan.md.-//! Ported from handlers/launch.py, open_files.py, open_symbol_editor.py,-//! open_footprint_editor.py, close_windows.py, kicad_ui.py, kicad_windows.py, state.py,-//! software_opengl.py, place_footprint.py, fix_keyboard.py and the dispatch wrapper in-//! server.py (dialog sweep, foreground etiquette, window labelling).-//!-//! Division of labour: the bridge owns KiCad, ab owns Windows. Window enumeration with-//! z-order, every screenshot (with owned popups and coordMap), graceful closes, toasts and-//! captions go to ab through `kicad_core::ab`; the local platform layer-//! (`kicad_platform::native()`) is the fallback for the list, and the ONLY path for the-//! things ab has no verb for: spawning without activation, the native menu walk plus-//! WM_COMMAND, posted keys and clicks to one hwnd, WM_SETTEXT, button clicks on a dialog,-//! UIA Invoke/SetValue, and the canvas-painted probe (raw pixels, KiCad-specific).-//! Nothing in this file names an OS: when a platform method answers "not implemented on-//! <os>", the verb answers `kicad_platform::unsupported`.-//!-//! The ladder (plan, "Front-end control policy"): every window-opening verb records the-//! rung it used as `mechanism`: "menu" (native menu walk + WM_COMMAND), "uia" (UI-//! Automation Invoke/SetValue), "post" (PostMessage keys/clicks/WM_SETTEXT), "spawn" (a-//! process started without activation). SendInput (cursor clicks, focused chords) is out-//! of scope for the native build: those opt-ins answer `not_supported_on_platform`.-//!-//! Etiquette: background-first. Nothing is foregrounded unless the caller passes-//! foreground/showToUser/bringToFront WITH foregroundReason (else errorCode-//! foreground_reason_required). After every window verb the dialog sweep runs (progress-//! dialogs are never dismissed) and the one-shot focus check pushes a KiCad window back-//! once if it took the foreground from a user window. The sentinel/WinEventHook/park-//! guardian is phase 4 and is deliberately not here.--use std::collections::BTreeMap;-use std::path::{Path, PathBuf};-use std::sync::Mutex;-use std::time::{Duration, Instant};--use serde_json::{json, Map, Value};--use crate::catalog::{Mechanism, Verb};-use crate::util::*;-use kicad_core::detect::KicadInfo;-use kicad_core::libraries::{self, LibCtx};-use kicad_core::windows_model as wm;-use kicad_core::windows_model::{DialogAction, WindowKind};-use kicad_core::{ab, bridge_log, kicad_common, pcm};-use kicad_core::window_memory::{self, Restore};-use kicad_platform::{native, unsupported, Placement, WindowInfo};--// ── Catalog ───────────────────────────────────────────────────────────────────--pub static VERBS: &[Verb] = &[- Verb {- name: "kicad_refresh_board_views",- summary: "Refresh the exact PCB editor and its already-open linked 3D viewer without focus or mouse.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,- input: "{pcbHwnd}", example: "kicad_refresh_board_views {pcbHwnd:12345}",- hint: "Requires an existing uniquely bound viewer. Discovers the exact View > Refresh menu on both windows before invoking either. Returns refreshRequested, not rendering proof; inspect kicad_state screenshots after redraw.",- related: &["kicad_state", "kicad_open_3d_viewer", "kicad_silk_text_batch"],- pitfalls: &["Does not open windows, substitute another editor/viewer, save the board, or claim that the 3D model has finished rebuilding."],- },- Verb {- name: "kicad_launch",- summary: "Cold-start KiCad (project manager) when nothing is running.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"project\"?: \"C:/.../x.kicad_pro\", \"waitSeconds\"?: 15, \"traceMasks\"?: \"KICAD_...\"}",- example: "kicad_launch {}",- hint: "Idempotent: no-op with a hint if KiCad is already up. Opens in the BACKGROUND (no focus steal). Optional project: path to a .kicad_pro. Each window is put back on the monitor the user last had it on (placementRestored says what moved); a rect on a monitor that is no longer connected is left alone.",- related: &["kicad_open_editors", "kicad_window_info", "kicad_readiness", "kicad_upgrade"],- pitfalls: &["first launch on a fresh install can show a setup wizard; kicad_window_info self-heals benign dialogs",- "placement memory is per window KIND (hwnds do not survive a restart) and lives in ~/.adom/kicad-bridge-placement.json; a box that has never run KiCad under the bridge has none, so the first launch lands wherever Windows puts it"],- },- Verb {- name: "kicad_open_board",- summary: "Open a .kicad_pcb in the PCB editor (pcbnew).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"waitSeconds\"?: 30, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",- hint: "Pass an ABSOLUTE filePath; opens the board in pcbnew and returns once the window is up. Optional kicadVersion pins a specific install.",- related: &["kicad_open_3d_viewer", "kicad_run_drc", "kicad_screenshot_all", "kicad_close"],- pitfalls: &["filePath must be ABSOLUTE; a relative/project-dir path fails", "if the board is already open you get the existing window, not a fresh reload"],- },- Verb {- name: "kicad_open_schematic",- summary: "Open a .kicad_sch in the schematic editor (eeschema).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"waitSeconds\"?: 30, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_schematic {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",- hint: "Absolute filePath to a .kicad_sch; opens eeschema. Optional kicadVersion.",- related: &["kicad_run_erc", "kicad_export_bom_csv", "kicad_screenshot_all", "kicad_close"],- pitfalls: &["filePath must be ABSOLUTE", "open the top-level .kicad_sch, not a sub-sheet, to load the whole design"],- },- Verb {- name: "kicad_open_symbol_editor",- summary: "Open the Symbol Editor (optionally at a symbol).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"symbolName\"?: \"RP2040\", \"libraryName\"?: \"Adom\", \"rescan\"?: bool, \"waitInline\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_symbol_editor {\"symbolName\":\"RP2040\",\"libraryName\":\"Adom\"}",- hint: "Opens the Symbol Editor; pass symbolName (+ libraryName to disambiguate short names) to load that symbol into the canvas. The load is VERIFIED against the parsed library:symbol title: if the requested symbol did not load you get success:false with editorOpened:true, not a silent empty editor.",- related: &["kicad_install_symbol", "kicad_close_symbol_editor", "kicad_screenshot_all"],- pitfalls: &["the symbol must already be in an installed sym-lib-table library (install_symbol first)", "a cold Symbol Editor indexes every library before its window shows; the verb waits, and returns a retryable symbol_editor_starting contract if the transport budget runs out"],- },- Verb {- name: "kicad_open_footprint_editor",- summary: "Open the Footprint Editor (optionally at a footprint).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"footprintName\"?: \"SOT-23\", \"libraryName\"?: \"Adom\", \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_footprint_editor {\"footprintName\":\"SOT-23\",\"libraryName\":\"Adom\"}",- hint: "Opens the Footprint Editor; pass library/footprint to jump to one.",- related: &["kicad_install_footprint", "kicad_close_footprint_editor", "kicad_screenshot_all"],- pitfalls: &["the footprint must be in an installed fp-lib-table library (install_footprint first)", "allowCursorClick (the SendInput launcher click) is not in the native build: the background menu and UIA rungs are the only ones"],- },- Verb {- name: "kicad_menu_items",- summary: "List a KiCad window's menu bar items (path, id, accelerator), optionally filtered by a substring: what kicad_send_key {menu} and kicad_3d_view can fire.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,- input: "{\"hwnd\"?: 123, \"contains\"?: \"zoom\"}",- example: "kicad_menu_items {\"hwnd\":123456,\"contains\":\"zoom\"}",- hint: "Read-only, background. The last path element is the label a menu command matches (exact label first, then substring).",- related: &["kicad_send_key", "kicad_3d_view", "kicad_window_info"],- pitfalls: &["menus differ between KiCad versions and editors; list before you assume a label"],- },- Verb {- name: "kicad_3d_view",- summary: "Drive the 3D Viewer without the mouse: zoom, rotate, view orientation, flip, or a scripted tour; menu commands posted to the viewer in the background.",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 120,- input: "{\"action\": \"zoom_in|zoom_out|fit|rotate_x_cw|rotate_x_ccw|rotate_y_cw|rotate_y_ccw|rotate_z_cw|rotate_z_ccw|view_top|view_bottom|view_front|view_back|view_left|view_right|flip|tour|actions\", \"count\"?: 3, \"pauseMs\"?: 700, \"hwnd\"?: 123, \"steps\"?: [[\"rotate_z_cw\", 8], [\"zoom_in\", 3]]}",- example: "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}",- hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. pan_left, pan_right, pan_up and pan_down discover Move Board menu commands at runtime. Reference framing is not yet available.",- related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],- pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],- },- Verb {- name: "kicad_open_3d_viewer",- summary: "Open the 3D board viewer for a .kicad_pcb.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,- input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",- hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",- related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],- pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],- },- Verb {- name: "kicad_open_editors",- summary: "List currently open KiCad editor windows.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,- input: "{}",- example: "kicad_open_editors {}",- hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",- related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],- pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],- },- Verb {- name: "kicad_close",- summary: "Close KiCad (editors first, then the project manager).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"force\"?: bool, \"discardChanges\"?: bool}",- example: "kicad_close {}",- hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",- related: &["kicad_open_editors", "kicad_window_info"],- pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],- },- Verb {- name: "kicad_close_symbol_editor",- summary: "Close the Symbol Editor window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_symbol_editor {}",- hint: "Closes the Symbol Editor if open; no-op otherwise.",- related: &["kicad_open_symbol_editor", "kicad_close"],- pitfalls: &["unsaved edits may prompt; save first if you made changes"],- },- Verb {- name: "kicad_close_footprint_editor",- summary: "Close the Footprint Editor window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_footprint_editor {}",- hint: "Closes the Footprint Editor if open; no-op otherwise.",- related: &["kicad_open_footprint_editor", "kicad_close"],- pitfalls: &["unsaved edits may prompt; save first"],- },- Verb {- name: "kicad_close_3d_viewer",- summary: "Close the 3D viewer window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_3d_viewer {}",- hint: "Closes the 3D viewer if open; no-op otherwise.",- related: &["kicad_open_3d_viewer", "kicad_close"],- pitfalls: &[],- },- Verb {- name: "kicad_foreground",- summary: "Bring a KiCad window in front of the user: the one sanctioned exception to the never-foreground rule. A reason written for the person is required; the bridge captions it first.",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"reason\": \"why the person needs to see this (10 characters or more)\", \"editor\"?: \"pcb|sch|fp|sym|3d|manager\", \"hwnd\"?: 123456}",- example: "kicad_foreground {\"editor\":\"pcb\",\"reason\":\"the routed board is ready for your review\"}",- hint: "Without editor or hwnd the PCB editor is chosen, else the schematic editor, else any KiCad window. Every other verb works in the background: use this only when the person has to look or act.",- related: &["kicad_window_info", "kicad_screenshot_all", "kicad_show_2d_board"],- pitfalls: &["Refused (reason_required) without a reason of 10 characters or more.", "It takes the user's screen: ask first when someone may be working."],- },- Verb {- name: "kicad_dialog_log",- summary: "What the bridge's dialog sweeps pressed since it started, newest last: each KiCad dialog it dismissed (benign notices, error boxes after a verb), with its title, body, the button and when.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 10,- input: "{\"limit\"?: 25}",- example: "kicad_dialog_log {\"limit\":10}",- hint: "entries[{at, title, body, benign, action, verb}]. A dialog left open (read-only verbs leave non-benign ones) is not here: kicad_window_info lists those. Kept in memory: a restarted bridge starts empty.",- related: &["kicad_dismiss_dialogs", "kicad_window_info", "kicad_screenshot_all"],- pitfalls: &["The last 200 dismissals are kept."],- },- Verb {- name: "kicad_dismiss_dialogs",- summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,- input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",- example: "kicad_dismiss_dialogs {\"all\":true}",- hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",- related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],- pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],- },- Verb {- name: "kicad_window_info",- summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,- input: "{\"autoDismiss\"?: true}",- example: "kicad_window_info {}",- hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",- related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],- pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],- },- Verb {- name: "kicad_state",- summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,- input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",- example: "kicad_state {\"includeImages\":false}",- hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",- related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],- pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],- },- Verb {- name: "kicad_screenshot_all",- summary: "Screenshot every open KiCad window.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,- input: "{}",- example: "kicad_screenshot_all {}",- hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",- related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],- pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],- },- Verb {- name: "kicad_send_key",- summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}",- example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",- hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",- related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],- pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],- },- Verb {- name: "kicad_teardrops",- summary: "Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after.",- mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"vias\"?: true, \"pthPads\"?: true, \"smdPads\"?: true, \"tJunctions\"?: false (track to track), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops)}",- example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",- hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). before and after count the teardrop areas; KiCad adds them as one Undo step. Save with kicad_save_board, then check with kicad_run_drc.",- related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],- pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],- },- Verb {- name: "kicad_click",- summary: "Click at coordinates inside a KiCad window.",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",- example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",- hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",- related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],- pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],- },- Verb {- name: "kicad_fix_keyboard",- summary: "Reset stuck modifier keys on a KiCad window.",- mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,- input: "{}", example: "kicad_fix_keyboard {}",- hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",- related: &["kicad_send_key"],- pitfalls: &[],- },- Verb {- name: "kicad_place_footprint",- summary: "Deterministically place a footprint into a board preview.",- mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,- input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",- example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",- hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; 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"],- },- Verb {- name: "kicad_enable_software_opengl",- summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 240,- input: "{}", example: "kicad_enable_software_opengl {}",- hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",- related: &["kicad_dismiss_dialogs", "kicad_upgrade"],- pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],- },- Verb {- name: "kicad_rescan_libraries",- summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",- mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,- input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",- example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",- hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",- related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],- pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],- },-];--/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and-/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).-const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint",- // loads a footprint into the editor, which KiCad may raise once the load finishes (a late raise on a slow box: winvm)- "kicad_rescan_libraries"];--/// Verbs that touch no window at all (no sweep, no focus check).-const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];--pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {- if !VERBS.iter().any(|v| v.name == command) {- return None;- }- // Caller identity, so every ab callback made while serving this verb is attributed- // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.- ab::set_caller_from_args(args);- let gui = !NO_GUI.contains(&command);- let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };- if gui {- // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.- native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));- }- let mut out = match command {- "kicad_launch" => launch(state, args),- "kicad_rescan_libraries" => rescan_libraries(state, args),- "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),- "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),- "kicad_open_symbol_editor" => open_symbol_editor(state, args),- "kicad_open_footprint_editor" => open_footprint_editor(state, args),- "kicad_open_3d_viewer" => open_3d_viewer(args),- "kicad_3d_view" => view_3d(args),- "kicad_refresh_board_views" => refresh_board_views(args),- "kicad_menu_items" => menu_items(args),- "kicad_open_editors" => open_editors(),- "kicad_close" => close_kicad(state, args),- "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),- "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),- "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),- "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),- "kicad_foreground" => foreground(args),- "kicad_dialog_log" => dialog_log(args),- "kicad_window_info" => window_info(state, args),- "kicad_state" => kicad_state(state, args),- "kicad_screenshot_all" => screenshot_all(state),- "kicad_send_key" => send_key(args),- "kicad_teardrops" => teardrops(state, args),- "kicad_click" => click(args),- "kicad_fix_keyboard" => fix_keyboard(),- "kicad_place_footprint" => place_footprint(state, args),- "kicad_enable_software_opengl" => enable_software_opengl(state),- _ => return None,- };- if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {- post_verb(state, command, args, &mut out, fg_before);- }- if gui {- native().etiquette_end_verb(command);- }- ab::clear_caller();- Some(out)-}--/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,-/// the one-shot focus check, window labelling.-/// Set when KiCad's own "Could not use OpenGL" box was seen; cleared by kicad_enable_software_opengl.-pub(crate) static NO_OPENGL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);--fn is_3d_verb(command: &str) -> bool {- matches!(command, "kicad_open_3d_viewer" | "kicad_show_3d_board" | "kicad_show_3d_chip" | "kicad_3d_view")-}--pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {- let spawns = SPAWNS_WINDOW.contains(&command);- let info = state.kicad_info();- // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after- // every GUI verb so the AI never runs blind past one and the next verb is unblocked.- let rounds = if spawns { 5 } else { 1 };- // Wiki #120: a read-only verb never presses anything. It still reports what it saw.- let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);- if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {- let had_error = swept["hadError"].as_bool().unwrap_or(false);- if had_error {- let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();- out["_dialogHint"] = json!(if read_only {- format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))- } else {- format!("KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.", swept["count"], titles.join(", "))- });- }- // KiCad says this once per process start on a machine without usable OpenGL (a VM with no- // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D- // verbs stop answering ok over a black canvas (wiki #100, seen on winvm).- if swept["dialogs"].as_array().is_some_and(|a| a.iter().any(|d| d["body"].as_str().unwrap_or("").contains("Could not use OpenGL"))) {- NO_OPENGL.store(true, std::sync::atomic::Ordering::Relaxed);- }- out["_autoDialogs"] = swept;- }- if is_3d_verb(command) && NO_OPENGL.load(std::sync::atomic::Ordering::Relaxed) && out.get("success").and_then(Value::as_bool).unwrap_or(true) {- out["canvasRendered"] = json!(false);- out["renderVerified"] = json!(false);- out["_renderHint"] = json!("KiCad reported \"Could not use OpenGL\" on this machine (no GPU: a VM or a remote session). The PCB and schematic canvases fall back to software, but the 3D viewer needs OpenGL 1.5 and shows a BLACK canvas: do not present its frame as proof. Fix: kicad_close, then kicad_enable_software_opengl (Mesa on the CPU, slow but correct, for GPU-less hosts only), then open again.");- }- // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a- // KiCad window comes to the front is an explicit foreground flag WITH a reason.- let wants_fg = user_wants_foreground(args);- let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);- if wants_fg && spawns {- std::thread::sleep(Duration::from_secs(1)); // let the window exist- let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();- let mut shown: Option<u64> = None;- if !reason.is_empty() {- if let Some(h) = verb_window(command, out) {- if bring_to_user(h, &reason) {- shown = Some(h);- }- }- }- out["_broughtToUser"] = json!(shown.is_some());- out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });- if shown.is_none() {- out["errorCode"] = json!("foreground_reason_required");- out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");- out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");- }- } else if spawns && success {- let o = out.as_object_mut().unwrap();- o.entry("windowEtiquette").or_insert(json!("background"));- o.entry("openedInBackground").or_insert(json!(true));- o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));- }- // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the- // foreground from a user window during this verb? Push it back once, and say so.- let mut events = Vec::new();- if !(wants_fg && out["_broughtToUser"] == json!(true)) {- // With the etiquette loop running (Windows) the one-shot push would be a second action- // on a window the loop already handled once; keep it only where no loop exists.- // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor- // re-raises itself when its board finishes loading WITHOUT a foreground transition- // (it already was the foreground while loading), so the loop sees nothing to bounce- // and the user's window stays covered. For spawn verbs the one-shot check runs- // regardless, twice: now and after a short settle for the late re-raise.- let loop_running = native().etiquette_debug() != Value::Null;- if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {- events.push(ev);- }- if spawns {- // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress- // dialog gone), because that is the moment it raises itself; then check twice.- for _ in 0..16 {- let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));- if !loading {- break;- }- sleep_ms(500);- }- sleep_ms(700);- if let Some(ev) = one_shot_focus_check(fg_before) {- events.push(ev);- }- sleep_ms(700);- if let Some(ev) = one_shot_focus_check(fg_before) {- events.push(ev);- }- }- }- // kicad_state already carries the loop's ring buffer; never clobber it with the- // per-verb list (which is empty while the loop runs).- if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {- out["focusEvents"] = json!(events);- }- if spawns {- out["_userFocusRestored"] = json!(false);- }- // #12.3: label the KiCad window this verb raised with the owning AI thread, so the- // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.- if spawns && success {- let thread = ab::thread_name();- let label = match command {- "kicad_open_symbol_editor" => Some("Symbol Editor"),- "kicad_open_footprint_editor" => Some("Footprint Editor"),- "kicad_open_board" => Some("PCB Editor"),- "kicad_open_schematic" => Some("Schematic Editor"),- "kicad_open_3d_viewer" => Some("3D Viewer"),- _ => None,- };- if let (false, Some(word)) = (thread.is_empty(), label) {- if let Ok((wins, _)) = find_windows(false, true) {- if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {- if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {- out["_windowLabeledFor"] = json!(thread);- }- }- }- }- }- invalidate();-}--// ── Platform plumbing ─────────────────────────────────────────────────────────--fn is_not_impl(e: &str) -> bool {- e.contains(": not implemented on ")-}--/// Map a platform Result: "not implemented on <os>" becomes the honest-/// not_supported_on_platform reply; anything else is a platform_error.-fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {- r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })-}--fn exe_suffix() -> &'static str {- if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }-}--/// `<bin_dir>/<name>[.exe]` for the primary install.-fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {- let inst = info.primary()?;- let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));- if p.is_file() { Some(p) } else { None }-}--fn utc_stamp() -> String {- // 2026-09-11T12:34:56Z -> 20260911-123456- let s = kicad_common::now_utc_iso();- let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();- let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();- format!("{date}-{time}")-}--/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.-fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {- skip_setup_wizard();- let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());- let env = bridge_log::trace_env(trace_masks);- let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;- native().etiquette_register_pid(pid, None);- invalidate();- Ok((pid, log))-}--/// Is `name` a row of the user's own fp-lib-table (not only reachable through a nested table)?-fn user_table_row(ctx: &LibCtx, name: &str) -> bool {- ctx.fp_lib_table()- .and_then(|p| kicad_core::libraries::LibTable::parse_file(&p).ok())- .is_some_and(|t| t.entries.iter().any(|e| e.name == name && !e.lib_type.eq_ignore_ascii_case("Table")))-}--/// Before a KiCad process starts, and only while none runs: mark KiCad's setup wizard as done so-/// it never pops up in front of the user (see kicad_common::mark_first_run_done).-fn skip_setup_wizard() { if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() { return; }- let installs: Vec<(String, String)> = kicad_core::detect::detect().installs.iter().map(|i| (i.version.clone(), i.base_dir.clone())).collect();- for (v, base) in installs {+ let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();+ for v in versions { if let Some(cfg) = native().config_dir(&v) {- for line in kicad_core::install::ensure_global_tables(&cfg, Some(base.as_str()).filter(|b| !b.is_empty()), &v) {- eprintln!("[kicad-bridge] KiCad {v}: {line}");- }- // A KiCad installed after the bridge's prewarm (a distro package, a Flatpak) has never- // been seeded: without kicad.json its setup wizard opens, modal, on the first launch.- // Seeding writes only files that are absent, so a user's own settings are never touched.- if !cfg.join("kicad.json").is_file() {- let s = kicad_common::seed_first_run(&cfg);- eprintln!("[kicad-bridge] KiCad {v}: first-run config seeded before the first launch (written {:?}, merged {:?}, errors {:?})", s.written, s.merged, s.errors);- } match kicad_common::mark_first_run_done(&cfg) { Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"), Ok(false) => {}- Err(e) => eprintln!("[kicad-bridge] KiCad {v}: could not mark the setup wizard done: {e}"),- }- }- }-}--fn sleep_ms(ms: u64) {- std::thread::sleep(Duration::from_millis(ms));-}--fn user_wants_foreground(args: &Value) -> bool {- ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))-}--fn user_idle_seconds() -> Option<f64> {- native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)-}--// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────--#[derive(Clone, Debug, Default)]-pub(crate) struct Win {- pub(crate) hwnd: u64,- pub(crate) title: String,- class_name: String,- /// x, y, width, height.- rect: (i32, i32, i32, i32),- z: Option<i64>,- owner: u64,- pid: u32,- exe: String,- minimized: bool,- maximized: bool,- hung: bool,-}--impl Win {- fn kind(&self) -> WindowKind {- wm::classify(&self.title, &self.class_name, self.owner)- }- fn is_kicad(&self) -> bool {- if !self.exe.is_empty() {- return wm::is_kicad_exe(&self.exe);- }- // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail- // open on the title, like the Python fallback.- wm::title_looks_kicad(&self.title)- }- fn rect_json(&self) -> Value {- json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})- }- /// `z` is the window's position in the OS enumeration, which EnumWindows returns front- /// first, so a caller can tell which of two windows of a kind the user was looking at- /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.- fn from_info(w: &WindowInfo, z: usize) -> Win {- Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }- }-}--struct Cache {- at: Option<Instant>,- rows: Vec<Win>,- source: &'static str,-}--static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });-const CACHE_TTL: Duration = Duration::from_millis(250);--/// Call after anything that opens, closes, or retitles a KiCad window.-pub(crate) fn invalidate() {- if let Ok(mut c) = CACHE.lock() {- c.at = None;- c.rows.clear();- } } fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {- // Linux: the X server is the authority on KiCad's windows (pid, owner, state); ab's list is- // the fallback when the bridge cannot open the display itself.- if native().capabilities().os == "linux" {- if let Ok(rows) = native().list_windows() {- return Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:x11"));- }- } match ab::desktop_list_windows() { Ok(l) if !(l.windows.is_empty() && l.degraded) => { let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {- hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,- exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,- }).collect();- // ab before 2.1.12 carries no image/owner (they arrived together): fill both- // from the OS layer when it can, only for rows that lack the exe.- for r in rows.iter_mut() {- if r.exe.is_empty() {- if let Ok(Some(i)) = native().window_info(r.hwnd) {- r.exe = i.exe;- if r.owner == 0 { r.owner = i.owner; }- if r.class_name.is_empty() { r.class_name = i.class_name; }- }- }- }- Ok((rows, "ad:desktop_list_windows"))- }- _ => {- let rows = plat("window enumeration (list_windows)", native().list_windows())?;- Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:EnumWindows"))- }- }-}--/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.-fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {- let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;- let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);- if stale {- let (rows, src) = list_all_windows()?;- c.rows = rows;- c.source = src;- c.at = Some(Instant::now());- }- let src = c.source;- if all_windows {- return Ok((c.rows.clone(), src));- }- // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).- let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();- let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();- Ok((rows, src))-}--pub(crate) fn kicad_windows() -> Vec<Win> {- find_windows(false, false).map(|(w, _)| w).unwrap_or_default()-}--pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {- kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()-}--fn first_of_kind(kind: WindowKind) -> Option<u64> {- windows_of_kind(kind).first().map(|w| w.hwnd)-}--/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can-/// put it back. Called before anything the bridge does that destroys geometry (a restart, a-/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach-/// nothing, so they are skipped.-pub(crate) fn remember_placement() -> usize {- let now = utc_stamp();- let mut mem = window_memory::load();- let mut learned = 0;- // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the- // user was actually looking at is the one whose place is remembered for that kind.- let mut windows = kicad_windows();- windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));- let mut seen: Vec<WindowKind> = Vec::new();- for w in windows {- let kind = w.kind();- if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {- continue;- }- seen.push(kind);- // placement() reports the NORMAL rect plus the maximized flag, which is what has to- // come back; the enumerated rect is the fallback where that call is unavailable.- let place = native().placement(w.hwnd).unwrap_or(Placement {- x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,- });- if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {- learned += 1;- }- }- if learned > 0 {- if let Err(e) = window_memory::save(&mem) {- eprintln!("[placement] could not save the placement memory: {e}");- }- }- learned-}--/// Wiki #48: put each window back where the user had it, without activating it. A fresh-/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor-/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the-/// caller's reply can say what moved and what was left alone.-pub(crate) fn restore_placement() -> Vec<Value> {- let mem = window_memory::load();- if mem.is_empty() {- return Vec::new();- }- kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()-}--/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of-/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because-/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one-/// complaint with two halves. Returns None when nothing is remembered for that kind.-pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {- restore_one(&window_memory::load(), kind, hwnd)-}--fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {- let remembered = mem.get(kind.as_str())?;- let r = &remembered.place;- let current = match native().placement(hwnd) {- Ok(p) => p,- Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),- };- let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);- Some(match window_memory::decide(r, ¤t, on_monitor) {- Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),- Restore::Move(p) => match native().set_placement(hwnd, &p) {- Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,- "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},- "rememberedAt": remembered.at}),- Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),- },- })-}--fn find_project_manager() -> Option<u64> {- kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)-}--/// A window's current title without hanging on a modal-blocked thread.-pub(crate) fn title_of(hwnd: u64) -> String {- if let Ok(Some(i)) = native().window_info(hwnd) {- return i.title;- }- find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()-}--pub(crate) fn window_alive(hwnd: u64) -> bool {- match native().window_info(hwnd) {- Ok(Some(i)) => i.visible,- Ok(None) => false,- Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),- }-}--/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,-/// else the topmost KiCad window.-fn verb_window(command: &str, out: &Value) -> Option<u64> {- if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {- return Some(h);- }- let kind = match command {- "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),- "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),- "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),- "kicad_open_schematic" => Some(WindowKind::SchematicEditor),- "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),- _ => None,- };- let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();- kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))-}--// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────--static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());--fn record_focus_event(ev: Value) {- if let Ok(mut g) = FOCUS_EVENTS.lock() {- g.push(ev);- if g.len() > 200 {- let n = g.len() - 200;- g.drain(..n);- }- }-}--fn is_kicad_hwnd(hwnd: u64) -> bool {- if let Ok(Some(i)) = native().window_info(hwnd) {- return wm::is_kicad_exe(&i.exe);- }- kicad_windows().iter().any(|w| w.hwnd == hwnd)-}--/// capture_foreground before, compare after: if a KiCad window now holds the foreground-/// and the user had a non-KiCad window before, push KiCad back once (never activate the-/// user's window: Windows re-activates it naturally) and report the event.-fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {- // Part of the background guard: with the guard OFF the bridge moves no KiCad window at all.- // Before 1.0.48 this per-verb check ignored the switch, so "guard off" still pushed KiCad back- // after every verb and the A/B comparison showed nothing (AdomLapper, 2026-09-21).- if !native().etiquette_enabled() {- return None;- }- let before = fg_before?;- if is_kicad_hwnd(before) || native().is_shell_window(before) {- return None;- }- // What covers the user's window is the top of the Z order, not only the activated- // window: a KiCad editor raises itself when its board finishes loading without a- // foreground transition (gate on 2026-09-12).- let fg = native().foreground().ok().filter(|h| *h != 0);- let top = native().z_top().ok().filter(|h| *h != 0);- let after = match (fg, top) {- (Some(f), _) if is_kicad_hwnd(f) => f,- (_, Some(t)) if is_kicad_hwnd(t) => t,- _ => return None,- };- if after == before {- return None;- }- let title = title_of(after);- let pushed = native().push_to_background(after).is_ok();- // A Z-order push alone does not hold: the editor still owns the activation and raises- // itself again on its next paint. Give the user's window its activation back (this- // restores what the user had before the verb; it is not a foreground of ours).- // ASK for the user's window back, never force it (no thread-input attach): the forcing- // bring_to_front here was the same call that crashed Explorer when aimed at the taskbar.- let restored = native().request_foreground(before);- let action = match (pushed, restored) {- (true, true) => "pushed-to-background, user window restored",- (true, false) => "pushed-to-background",- (false, true) => "user window restored",- (false, false) => "push-failed",- };- let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});- record_focus_event(ev.clone());- Some(ev)-}--/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason-/// first, then restore and activate. Refused without a reason.-/// Every dialog a sweep dismissed, newest last (kicad_dialog_log): in memory, the last 200.-static DIALOG_LOG: std::sync::Mutex<std::collections::VecDeque<Value>> = std::sync::Mutex::new(std::collections::VecDeque::new());--fn log_dialog(entry: &Value, benign: bool) {- let at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);- let mut e = json!({"at": at, "title": entry["title"], "body": entry["body"], "benign": benign, "action": entry["action"]});- if let Some(s) = entry["screenshot"].as_str().filter(|s| !s.is_empty()) {- e["screenshot"] = json!(s);- }- if let Ok(mut l) = DIALOG_LOG.lock() {- l.push_back(e);- while l.len() > 200 {- l.pop_front();- }- }-}--/// kicad_dialog_log: the last `limit` dismissals.-fn dialog_log(args: &Value) -> Value {- let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25).clamp(1, 200) as usize;- let all: Vec<Value> = DIALOG_LOG.lock().map(|l| l.iter().cloned().collect()).unwrap_or_default();- let entries: Vec<Value> = all.iter().skip(all.len().saturating_sub(limit)).cloned().collect();- json!({"success": true, "entries": entries, "total": all.len(),- "output": format!("{} dialog(s) dismissed since the bridge started; {} shown", all.len(), entries.len()),- "_hint": "Newest last. at is Unix seconds. Dialogs left open are not here: kicad_window_info lists them."})-}--/// kicad_foreground: one KiCad window to the front, captioned with the reason first (bring_to_user).-fn foreground(args: &Value) -> Value {- let reason = arg_any(args, &["reason", "foregroundReason"]).unwrap_or("").trim().to_string();- if reason.chars().count() < 10 {- return fail("reason_required", "kicad_foreground needs a reason of 10 characters or more, written for the person", "Say why they need to see it, e.g. \"the routed board is ready for your review\".");- }- invalidate();- let kind = |k: &str| match k {- "pcb" | "board" => Some(WindowKind::PcbEditor),- "sch" | "schematic" => Some(WindowKind::SchematicEditor),- "fp" | "footprint" => Some(WindowKind::FootprintEditor),- "sym" | "symbol" => Some(WindowKind::SymbolEditor),- "3d" => Some(WindowKind::Viewer3d),- "manager" | "project" => Some(WindowKind::ProjectManager),- _ => None,- };- let hwnd = match (arg_any_u64(args, &["hwnd"]), arg_str(args, "editor")) {- (Some(h), _) => Some(h),- (None, Some(e)) => match kind(&e.to_lowercase()) {- Some(k) => first_of_kind(k),- None => return fail("bad_args", format!("unknown editor {e}"), "editor is pcb, sch, fp, sym, 3d or manager."),- },- (None, None) => first_of_kind(WindowKind::PcbEditor).or_else(|| first_of_kind(WindowKind::SchematicEditor)).or_else(|| kicad_windows().into_iter().map(|w| w.hwnd).next()),- };- let Some(h) = hwnd else {- return fail("no_window", "no KiCad window is open", "Open one first (kicad_open_board, kicad_open_schematic, kicad_launch).");- };- let title = kicad_windows().into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();- let shown = bring_to_user(h, &reason);- json!({"success": shown, "hwnd": h, "title": title, "broughtToUser": shown, "windowEtiquette": if shown { "foreground" } else { "background" },- "errorCode": if shown { Value::Null } else { json!("foreground_failed") },- "output": if shown { format!("{title} is in front of the user: {reason}") } else { format!("could not bring {title} forward") },- "_hint": "Hand the screen back when they are done: every other verb works in the background."})-}--fn bring_to_user(hwnd: u64, reason: &str) -> bool {- if reason.trim().is_empty() {- return false;- }- // The toast is debounced (settings.foregroundToastMinutes, 0 = never): ab mirrors it to- // the user's other boxes, and one per verb from a busy thread was worse than the- // foreground itself. The caption is local to the driven box and always shown.- let toast = foreground_toast_due();- if toast {- let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");- let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");- }- let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");- sleep_ms(1200); // let the caption render BEFORE the window moves- native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for- let ok = match native().bring_to_front(hwnd) {- Ok(b) => b,- Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),- Err(_) => false,- };- record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "toast": toast, "at": kicad_common::now_utc_iso()}));- ok-}--static LAST_FOREGROUND_TOAST: Mutex<Option<Instant>> = Mutex::new(None);--/// One foreground toast per `foregroundToastMinutes`; 0 means never. Records the time-/// when it says yes.-fn foreground_toast_due() -> bool {- let minutes = kicad_core::settings::get_settings().get("foregroundToastMinutes").and_then(Value::as_u64).unwrap_or(10);- if minutes == 0 {- return false;- }- let mut last = match LAST_FOREGROUND_TOAST.lock() {- Ok(g) => g,- Err(p) => p.into_inner(),- };- let due = last.map(|t| t.elapsed() >= Duration::from_secs(minutes * 60)).unwrap_or(true);- if due {- *last = Some(Instant::now());- }- due-}--// ── Dialogs (close_windows.py) ────────────────────────────────────────────────--#[derive(Clone, Debug, Default)]-pub(crate) struct Dialog {- pub(crate) hwnd: u64,- pub(crate) title: String,- class_name: String,- owner: u64,- pub(crate) body: String,- via_global_sweep: bool,-}--/// A dialog's message text from its Static children (never blocks on a hung window).-fn dialog_body_text(hwnd: u64) -> String {- let Ok(children) = native().child_windows(hwnd) else { return String::new() };- let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();- wm::dialog_body(&texts)-}--/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that-/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so-/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.-pub(crate) fn scan_dialogs() -> Vec<Dialog> {- let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };- let mut out = Vec::new();- let mut seen = Vec::new();- for w in all.iter().filter(|w| w.is_kicad()) {- if !wm::is_dialog(&w.class_name, w.owner) {- continue;- }- out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });- seen.push(w.hwnd);- }- for w in all.iter() {- if seen.contains(&w.hwnd) {- continue;- }- // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema- // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only- // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.- if !w.is_kicad() {- continue;- }- if wm::looks_like_global_dialog(&w.title, &w.class_name) {- out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });- seen.push(w.hwnd);- }- }- out-}--fn find_save_prompt() -> Option<Dialog> {- scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))-}--/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for-/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact-/// hwnd, no focus theft.-fn dismiss_dialog(hwnd: u64) -> String {- for tier in wm::DISMISS_BUTTON_TIERS {- if native().click_button(hwnd, tier).is_ok() {- invalidate();- return "clicked-button".into();- }- }- let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);- let _ = native().post_close(hwnd);- invalidate();- "escape+WM_CLOSE".into()-} /// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed. fn screenshot_dialog(hwnd: u64, title: &str) -> String {- match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None) {- Ok(s) => s.full_path,- Err(_) if native().capabilities().os == "linux" => local_screenshot(hwnd, &format!("dialog-{title}"), None).ok().and_then(|v| v["fullPath"].as_str().map(String::from)).unwrap_or_default(),- Err(_) => String::new(),- }+ ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default() } /// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops-/// attempting OpenGL on this machine. Called only right after dismissing the "could not-/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.-fn force_software_canvas(version: &str) -> bool {- let Some(root) = native().config_root() else { return false };- let mut targets: Vec<PathBuf> = Vec::new();- if !version.is_empty() {- let d = root.join(kicad_platform::major_minor(version));- if d.is_dir() {- targets.push(d.join("kicad_common.json"));- }- }- if targets.is_empty() {- targets = kicad_common::common_files(&root);- if targets.is_empty() && !version.is_empty() {- targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));- }- }- let mut changed = false;- for cfg in targets {- let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));- if !wm::force_software_canvas_doc(&mut doc) {- continue;- }- if let Some(p) = cfg.parent() {- let _ = std::fs::create_dir_all(p);- }- if let Ok(text) = serde_json::to_string_pretty(&doc) {- if std::fs::write(&cfg, text).is_ok() {- changed = true;- }- }- }- changed-}--/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with-/// dismiss_all), looping because dismissing one often spawns the next.-fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {- let mut dismissed = Vec::new();- let mut remaining = Vec::new();- let mut forced = false;- let mut shot: Vec<u64> = Vec::new();- let deadline = Instant::now() + Duration::from_secs(25);- for _pass in 0..5 {- if Instant::now() > deadline {- break;- }- remaining.clear();- let mut acted = false;- for d in scan_dialogs() {- match wm::sweep_decision(&d.title, &d.body, dismiss_all) {- DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),- DialogAction::Dismiss { benign } => {- let mut entry = json!({"title": d.title, "body": d.body});- if screenshot && !shot.contains(&d.hwnd) {- entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));- shot.push(d.hwnd);- }- entry["action"] = json!(dismiss_dialog(d.hwnd));- log_dialog(&entry, benign);- dismissed.push(entry);- acted = true;- if benign && !forced {- forced = force_software_canvas(&info.version);- }- }- DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),- }- }- if !acted {- break;- }- sleep_ms(400);- }- json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})-}--/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late-/// dialog, screenshots and reads each one found, expires it (never a progress dialog),-/// and returns None when nothing was ever seen.-fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration, dismiss: bool) -> Option<Value> {- let rounds = settle_rounds.max(1);- let mut found = Vec::new();- for r in 0..rounds {- invalidate();- found = scan_dialogs();- if !found.is_empty() {- break;- }- if r + 1 < rounds {- std::thread::sleep(settle_gap);- }- }- if found.is_empty() {- return None;- }- let mut dialogs = Vec::new();- let mut had_error = false;- let mut seen: Vec<u64> = Vec::new();- let deadline = Instant::now() + Duration::from_secs(25);- for _pass in 0..4 {- if Instant::now() > deadline {- break;- }- let mut acted = false;- invalidate();- for d in scan_dialogs() {- if seen.contains(&d.hwnd) {- continue;- }- seen.push(d.hwnd);- match wm::auto_sweep_decision(&d.title, &d.body) {- DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),- DialogAction::Dismiss { benign } if !dismiss => {- if !benign {- had_error = true;- }- dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));- }- DialogAction::Dismiss { benign } => {- if !benign {- had_error = true;- }- let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };- let action = dismiss_dialog(d.hwnd);- let entry = json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot});- log_dialog(&entry, benign);- dialogs.push(entry);- acted = true;- }- DialogAction::Leave => {}- }- }- if !acted {- break;- }- sleep_ms(350);- }- if dialogs.is_empty() {- return None;- }- Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))-}--/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume-/// once kicad_errors moves to a group that runs after this one.-pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {- scan_dialogs().into_iter().map(|d| {- let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();- bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }- }).collect()-}--// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────--/// Resolve a menu item whose label contains `needle` from the window's live menu bar and-/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.-fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {- let items = native().menu_tree(hwnd)?;- let want = needle.to_lowercase();- // an exact label wins over a longer one that contains it: "Zoom to Selection" must never- // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)- let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();- if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {- native().menu_invoke(hwnd, it.id)?;- invalidate();- return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));- }- // Wiki #121: a substring that matches more than one command is refused with the choices,- // never resolved to whichever item comes first.- let mut hits: Vec<(u32, String)> = Vec::new();- for it in &items {- let Some(label) = it.path.last() else { continue };- if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {- hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));- }- }- match hits.len() {- 0 => Ok(None),- 1 => {- let (id, label) = hits.remove(0);- native().menu_invoke(hwnd, id)?;- invalidate();- Ok(Some((id, label)))- }- _ => Err(format!(- "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",- hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")- )),- }-}--/// win_menu.find_by_accelerator: a chord to the menu command that carries it.-fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {- let items = native().menu_tree(hwnd)?;- let want = wm::norm_accel(chord);- for it in items {- if let Some(a) = &it.accel {- if wm::norm_accel(a) == want && it.id != 0 {- native().menu_invoke(hwnd, it.id)?;- invalidate();- let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();- return Ok(Some((it.id, label)));- }- }- }- Ok(None)-}--/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.-fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {- let t0 = Instant::now();- let mut reposted = false;- loop {- invalidate();- if let Some(h) = first_of_kind(kind) {- return Some(h);- }- if Instant::now() >= deadline {- return None;- }- if !reposted && t0.elapsed() > repost_after {- if let Some((host, id)) = repost {- let _ = native().menu_invoke(host, id);- }- reposted = true;- }- sleep_ms(500);- }-}--/// Every descendant (class, text, rect, visible) of a window with its parent handle.-/// The platform's `child_windows` returns the whole subtree with `owner` carrying the-/// PARENT handle (the Windows implementation is built for exactly this join).-#[derive(Clone, Debug)]-struct Child {- hwnd: u64,- class_name: String,- text: String,- rect: (i32, i32, i32, i32),- visible: bool,- parent: u64,-}--fn descendants(root: u64) -> Vec<Child> {- let Ok(kids) = native().child_windows(root) else { return Vec::new() };- kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()-}--/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".-fn find_search_edit(frame: u64) -> Option<Child> {- let all = descendants(frame);- all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()-}--static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());--/// The tree search control, showing the Library Tree pane through the frame's own View-/// menu when the user has it hidden (toggle at most once per editor per minute).-fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {- if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {- return Some(e);- }- let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);- if may_toggle {- match menu_invoke_by_text(frame, "library tree") {- Ok(Some((id, _))) => {- trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));- if let Ok(mut g) = TREE_TOGGLED.lock() {- g.retain(|(h, _)| *h != frame);- g.push((frame, Instant::now()));- }- }- Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),- Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),- }- }- for _ in 0..16 {- if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {- return Some(e);- }- sleep_ms(250);- }- None-}--/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).-fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {- if native().uia_set_value(edit, "", text).is_ok() {- return Ok("uia");- }- native().set_text(edit, text).map(|_| "post")-}--fn post_vk(hwnd: u64, vk: u16) {- let _ = native().post_key(hwnd, vk, None);-}--/// Which editor title check applies during navigation.-#[derive(Clone, Copy)]-enum Verify<'a> {- Symbol { name: &'a str, library: &'a str },- Footprint { name: &'a str, library: &'a str },- Never,-}--fn verify_title(title: &str, v: Verify<'_>) -> bool {- match v {- Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),- Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),- Verify::Never => false,- }-}--/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the-/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,-/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.-/// Returns (confirmed, trace, mechanism).-fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) { let mut steps: Vec<String> = Vec::new(); let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)}); let mut mechanism = "post";- // Linux: GTK draws the tree pane itself (no child windows), so the filter is the accessible "Search"- // field: its text set through the accessibility bus, then its activate action, which is the Enter KiCad's- // search box answers by loading the match it selected. Background, no focus.- if native().capabilities().os == "linux" {- let ok = linux_tree_nav(frame, filter, verify, &mut steps);- trace["steps"] = json!(steps);- return (ok, trace, "atspi");- } let Some(edit) = ensure_tree_search(frame, &mut steps) else { steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into()); trace["steps"] = json!(steps);- return (false, trace, mechanism);- };- trace["editHwnd"] = json!(edit.hwnd);- let mut set = set_filter_text(edit.hwnd, filter);- if let Err(e) = &set {- // A slow box (2 cores, software rendering) spends many seconds loading every library the- // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out- // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer- // again, then set the text once more.- steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));- let mut waited = 0;- while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {- sleep_ms(500);- waited += 1;- }- sleep_ms(800);- set = set_filter_text(edit.hwnd, filter);- }- match set {- Ok(m) => {- mechanism = m;- steps.push(format!("filter text set via {m}"));- }- Err(e) => {- steps.push(format!("could not set the filter text: {e}"));- trace["steps"] = json!(steps);- return (false, trace, mechanism);- }- }- sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild- // Down THROUGH the filter box: KiCad forwards nav keys to the tree.- post_vk(edit.hwnd, wm::VK_DOWN);- sleep_ms(500);- steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));- // Tree candidates: tall siblings under the Libraries pane (wx generic controls share- // bland class names, so geometry is the discriminator) plus any wxDataView.- let all = descendants(frame);- let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);- let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);- let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();- trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());- let title_ok = || verify_title(&title_of(frame), verify);- // Round 1: Enter posted straight to each tree candidate.- for c in &cands {- post_vk(c.hwnd, wm::VK_RETURN);- }- for _ in 0..6 {- sleep_ms(700);- if title_ok() {- steps.push("VK_RETURN to tree candidates -> title confirms".into());- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));- // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside- // the tree: Down (first lands on the library node, second on the symbol row), then Enter.- for downs in 1..=3 {- for c in &cands {- post_vk(c.hwnd, wm::VK_DOWN);- }- sleep_ms(400);- for c in &cands {- post_vk(c.hwnd, wm::VK_RETURN);- }- for _ in 0..5 {- sleep_ms(600);- if title_ok() {- steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));- }- // Round 2: a posted double-click on the first visible row (just under the header).- for c in &cands {- let x = (c.rect.2 / 3).max(10);- let y = 58;- let _ = native().post_click(c.hwnd, x, y, false);- let _ = native().post_click(c.hwnd, x, y, true);- }- for _ in 0..8 {- sleep_ms(700);- if title_ok() {- steps.push("posted double-click on first row -> title confirms".into());- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));- trace["steps"] = json!(steps); (false, trace, mechanism) } -/// navigate_tree on Linux (see there): the library tree's accessible Search field.-fn linux_tree_nav(frame: u64, filter: &str, verify: Verify<'_>, steps: &mut Vec<String>) -> bool {- let title_ok = || verify_title(&title_of(frame), verify);- for attempt in 1..=2 {- // a fresh editor loads every library first and answers nothing meanwhile- let mut set = native().uia_set_value(frame, "Search", filter);- let mut waited = 0;- while set.is_err() && waited < 20 {- sleep_ms(1000);- waited += 1;- set = native().uia_set_value(frame, "Search", filter);- }- match &set {- Ok(()) => steps.push(format!("attempt {attempt}: filter {filter:?} set in the Search field (AT-SPI)")),- Err(e) => {- steps.push(format!("attempt {attempt}: could not set the Search field: {e}"));- return false;- }- }- sleep_ms(1400); // LIB_TREE search debounce + tree rebuild- // the search selects its best match; activating that row loads it (as a double-click does)- match native().tree_activate_selected(frame) {- Ok(m) => steps.push(format!("attempt {attempt}: selected tree row activated ({m})")),- Err(e) => steps.push(format!("attempt {attempt}: selected tree row not activated: {e}")),- }- for _ in 0..10 {- sleep_ms(500);- if title_ok() {- steps.push(format!("title confirms: {:?}", title_of(frame)));- return true;- }- }- steps.push(format!("attempt {attempt}: title still {:?}", title_of(frame)));- }- false-}- // ── Opening an editor frame: menu walk, then UIA ────────────────────────────── struct Budget {- t0: Instant,- limit: Duration,-}--impl Budget {- fn new(args: &Value) -> Budget {- let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);- Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }- }- fn out(&self) -> bool {- self.t0.elapsed() > self.limit- }- fn elapsed_sec(&self) -> u64 {- self.t0.elapsed().as_secs()- }-}--struct OpenSpec {- kind: WindowKind,- /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").- menu_needle: &'static str,- uia_name: &'static str,- /// Frames whose menu bar / UIA tree carries the launcher, in preference order- /// (a running editor of the same family first, the project manager last).- hosts: &'static [WindowKind],- error_code: &'static str,- starting_code: &'static str,- label: &'static str,-}--const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };-const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };--/// The early-return contract when a slow cold start would outlive the transport budget-/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.-fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {- invalidate();- if first_of_kind(spec.kind).is_none() {- return json!({- "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,- "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,- "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),- "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),- });- }- json!({- "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",- "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),- "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),- "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",- })-}--/// Make sure the project manager exists (spawn kicad.exe in the background if not).-fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {- invalidate();- if let Some(h) = find_project_manager() {- return Ok(h);- }- let Some(exe) = kicad_exe(info, "kicad") else {- return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));- };- spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;- pathways.push("spawned-project-manager".into());- let deadline = Instant::now() + Duration::from_secs(10);- loop {- sleep_ms(500);- invalidate();- if let Some(h) = find_project_manager() {- sleep_ms(2000);- return Ok(h);- }- if Instant::now() > deadline {- return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));- }- }-}--/// Open an editor frame: an existing one wins; else the native menu walk on each host-/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).-fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {- let out = open_editor_frame_inner(info, spec, budget, pathways);- // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the- // primary display. An existing window is the user's own and is never moved.- if let Ok((hwnd, mechanism)) = &out {- if *mechanism != "existing" {- if let Some(row) = restore_placement_one(spec.kind, *hwnd) {- if row["moved"] == json!(true) {- pathways.push(format!("placement-restored:{}", spec.kind.as_str()));- }- }- }- }- out-}--fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {- invalidate();- if let Some(h) = first_of_kind(spec.kind) {- return Ok((h, "existing"));- }- // Hosts: a live editor frame of the same family first, then the project manager- // (spawned in the background when nothing at all is running).- let mut hosts: Vec<u64> = Vec::new();- for k in spec.hosts {- if *k == WindowKind::ProjectManager {- continue;- }- hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));- }- if spec.hosts.contains(&WindowKind::ProjectManager) {- let pm = match find_project_manager() {- Some(h) => h,- None if hosts.is_empty() => ensure_project_manager(info, pathways)?,- None => 0,- };- if pm != 0 {- hosts.push(pm);- }- }- if hosts.is_empty() {- return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));- }- // Rung: native menu walk + WM_COMMAND (background, version-proof ids).- let mut menu_unsupported: Option<Value> = None;- for &host in &hosts {- if budget.out() {- return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));- }- match menu_invoke_by_text(host, spec.menu_needle) {- Ok(Some((id, _label))) => {- pathways.push(format!("menu-walk-id-{id}"));- pathways.push("wm_command".into());- let deadline = Instant::now() + Duration::from_secs(30);- if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {- return Ok((h, "menu"));- }- pathways.push("wm_command-no-window".into());- }- Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),- Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }- Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),- }- }- // Rung: UI Automation Invoke by accessible name (no focus, no cursor).- let mut uia_ran = false;- for &host in &hosts {- if budget.out() {- return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));- }- match native().uia_invoke(host, spec.uia_name) {- Ok(_) => {- uia_ran = true;- pathways.push("uia".into());- let deadline = Instant::now() + Duration::from_secs(30);- if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {- return Ok((h, "uia"));- }- pathways.push("uia-no-window".into());- }- Err(e) if is_not_impl(&e) => {- if let Some(u) = menu_unsupported.take() {- return Err(u);- }- pathways.push("uia-unsupported".into());- }- Err(e) => {- uia_ran = true;- pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));- }- }- }- if let (Some(u), false) = (menu_unsupported, uia_ran) {- return Err(u);- }- let idle = user_idle_seconds();- let busy = idle.map(|i| i < 120.0).unwrap_or(false);- Err(json!({- "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,- "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,- if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),- "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",- }))-}--/// Ask an open editor to close so the next open re-indexes the libraries: ab's-/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is-/// measured here. Returns (gone, how).-fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {- let how = match ab::desktop_close_window(hwnd, reason) {- Ok(_) => "ad:desktop_close_window",- Err(_) => {- if native().post_close(hwnd).is_err() {- return (false, "none");- }- "local:WM_CLOSE"- }+pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+ let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {+ Ok(shot) => shot.to_json(),+ Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}), };- invalidate();- let deadline = Instant::now() + timeout;- while Instant::now() < deadline {- sleep_ms(300);- invalidate();- if !window_alive(hwnd) {- return (true, how);- }- }- (false, how)-}--// ── kicad_launch ──────────────────────────────────────────────────────────────--pub(crate) fn launch(state: &mut State, args: &Value) -> Value {- let existing = match find_windows(false, true) {- Ok((w, _)) => w,- Err(e) => return e,- };- if !existing.is_empty() {- return json!({- "success": true, "alreadyRunning": true,- "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),- "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),- "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",- });- }- let project = arg_str(args, "project").unwrap_or("").trim().to_string();- if !project.is_empty() && !Path::new(&project).exists() {- return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");- }- let info = state.kicad_info();- let Some(exe) = kicad_exe(&info, "kicad") else {- return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");- };- let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };- let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {- Ok(x) => x,- Err(mut e) => {- if e["errorCode"] != json!("not_supported_on_platform") {- e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));- }- return e;- }- };- let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);- let deadline = Instant::now() + Duration::from_secs_f64(wait_s);- let mut windows = Vec::new();- while Instant::now() < deadline {- windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();- if !windows.is_empty() {- break;- }- sleep_ms(500);- }- let log_path = log.as_ref().map(|p| norm(p));- if !windows.is_empty() {- // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor- // the user last used, not on whatever Windows defaults to.- sleep_ms(1200);- let restored = restore_placement();- return json!({- "success": true, "launched": true, "pid": pid, "mechanism": "spawn",- "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),- "output": format!("KiCad launched; {} window(s) up.", windows.len()),- "logPath": log_path, "placementRestored": restored,- "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",- "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],- });- }- json!({- "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,- "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),- "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",- "statusVerb": "kicad_window_info",- })-}--// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────--pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {- let info = state.kicad_info();- if !info.installed {- return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");- }- let Some(file_path) = arg_str(args, "filePath") else {- return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");- };- let path = PathBuf::from(file_path);- if !path.exists() {- return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");- }- let (exe_name, label, lint_verb) = match kind {- WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),- _ => ("eeschema", "schematic", "kicad_lint_schematic"),- };- let Some(exe) = kicad_exe(&info, exe_name) else {- return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");- };- let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();- let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();- // Already open: the existing window, not a fresh reload.- invalidate();- if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {- // Wiki #122: a title match is only a basename match. For a board, ask KiCad which- // file it really has open before reusing the window.- let mut path_verified = false;- if kind == WindowKind::PcbEditor {- if let Ok(open) = open_board_paths(Duration::from_secs(3)) {- let wanted = norm(&path);- if open.iter().any(|p| same_board_path(p, &wanted)) {- path_verified = true;- } else if !open.is_empty() {- // the API serves one editor: a board of the same file name elsewhere, or another editor's- // board entirely (a second pcbnew holds the API, and this window cannot be verified)- let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));- let (code, error) = if same_name {- ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))- } else {- ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))- };- return json!({- "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,- "error": error,- "openBoards": open, "requested": wanted,- "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.",- });- }- }- }- return json!({- "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",- "pathVerified": path_verified,- "output": format!("{label} editor already has {name} open"),- "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },- });- }- let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {- Ok(x) => x,- Err(mut e) => {- if e["errorCode"] != json!("not_supported_on_platform") {- e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));- e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");- }- return e;- }- };- // Verify by window title with one deadline (no nested sleeps).- let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);- let deadline = Instant::now() + Duration::from_secs_f64(wait_s);- if !native().capabilities().window_control {- return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);- }- let mut appeared: Option<Win> = None;- while Instant::now() < deadline {- invalidate();- let wins = windows_of_kind(kind);- appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();- if appeared.is_some() {- break;- }- if native().process_alive(pid) == Some(false) {- break;- }- sleep_ms(500);- }- let hint = format!(- "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."- );- let mut out = json!({- "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),- "output": format!("Launched {label} editor with {name}"),- "_hint": hint,- });- match appeared {- Some(w) => {- out["hwnd"] = json!(w.hwnd);- out["title"] = json!(w.title);- out["windowAppeared"] = json!(true);- // Wiki #48: this window was spawned by us, so Windows placed it on the primary- // display; put it back on the monitor the user keeps this editor on.- if let Some(row) = restore_placement_one(kind, w.hwnd) {- out["placementRestored"] = row;- }- }- None => {- let alive = native().process_alive(pid);- out["windowAppeared"] = json!(false);- if alive == Some(false) {- out["success"] = json!(false);- out["errorCode"] = json!("editor_exited");- out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));- out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));- } else {- out["statusVerb"] = json!("kicad_window_info");- out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));- }- }- }- out-}--/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's-/// own API (the editor reports the exact path it has open); a schematic by the process staying up.-#[allow(clippy::too_many_arguments)]-fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {- let wanted = norm(path);- let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();- let mut out = json!({- "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),- "output": format!("Launched {label} editor with {name}"),- "windowControl": false,- });- let mut confirmed = false;- let mut last_open: Vec<String> = Vec::new();- while Instant::now() < deadline {- if native().process_alive(pid) == Some(false) {- out["success"] = json!(false);- out["errorCode"] = json!("editor_exited");- out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));- out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));- return out;- }- if kind == WindowKind::PcbEditor {- if let Ok(open) = open_board_paths(Duration::from_secs(2)) {- if open.iter().any(|p| same_board_path(p, &wanted)) {- confirmed = true;- break;- }- last_open = open;- }- } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {- // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.- confirmed = true;- break;- }- sleep_ms(500);- }- out["loaded"] = json!(confirmed);- out["_hint"] = json!(if confirmed {- if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }- } else if last_open.is_empty() {- "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."- } else {- "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."- });- if !last_open.is_empty() && !confirmed {- out["openBoards"] = json!(last_open);- }- out-}--// ── kicad_open_symbol_editor ──────────────────────────────────────────────────--fn lib_ctx(info: &KicadInfo) -> LibCtx {- let inst = info.primary();- let version = inst.map(|i| i.version.clone()).unwrap_or_default();- let mut ctx = LibCtx {- version: version.clone(),- base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),- config_dir: if version.is_empty() { None } else { native().config_dir(&version) },- user_dir: if version.is_empty() { None } else { native().user_dir(&version) },- kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),- env_overrides: Default::default(),- };- ctx.load_env_overrides();- ctx-}--/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE-/// prefix match in either direction against the Adom library's real names.-fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {- let ctx = lib_ctx(info);- let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };- if !lib_path.is_file() {- return (None, "library file not found".into());- }- let names = libraries::list_symbols(&lib_path);- if names.iter().any(|n| n == requested) {- return (Some(requested.to_string()), "exact".into());- }- if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {- return (Some(n.clone()), "case-insensitive".into());- }- let r = requested.to_lowercase();- let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();- match pref.len() {- 1 => (Some(pref[0].clone()), "unique prefix match".into()),- 0 => (None, "no match".into()),- _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),- }-}--fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {- let ctx = lib_ctx(info);- libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)-}--pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if !info.installed {- return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");- }- let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();- let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();- // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve- // the caller's name against the library's real names BEFORE navigating and say so.- let mut note = Map::new();- if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {- let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);- match resolved {- Some(r) if r != symbol_name => {- note.insert("aliasedFrom".into(), json!(symbol_name));- note.insert("aliasHow".into(), json!(how));- symbol_name = r;- }- None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }- None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }- _ => {}- }- }- let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);- if !note.is_empty() {- if let Some(o) = out.as_object_mut() {- for (k, v) in ¬e {- o.insert(k.clone(), v.clone());- }- if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {- let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();- o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));- }- }- }- out-}--fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {- let spec = &SYMBOL_SPEC;- let budget = Budget::new(args);- let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);- let mut pathways: Vec<String> = Vec::new();- invalidate();- // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)- if !symbol_name.is_empty() && !rescan {- for w in windows_of_kind(WindowKind::SymbolEditor) {- if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {- let _ = native().show_no_activate(w.hwnd);- let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});- merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));- return v;- }- }- }- // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the- // open below indexes a fresh one. Opt-in: it closes a window the user may be using.- let mut rescanned = false;- let mut close_via = "";- if rescan {- if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {- let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));- close_via = how;- if gone {- rescanned = true;- pathways.push("rescan-closed-stale-editor".into());- } else {- pathways.push("rescan-close-failed".into());- }- }- }- let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {- Ok(x) => x,- Err(e) => return e,- };- if mechanism != "existing" {- sleep_ms(1500); // let the frame settle- }- let _ = native().show_no_activate(frame);- if symbol_name.is_empty() && library_name.is_empty() {- return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});- }- // Library-only browse mode (0.9.92): filter the tree to the library, no part load.- if symbol_name.is_empty() {- let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);- return json!({- "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,- "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),- "via": "win32-library-browse",- "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",- });- }- let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };- let verify = Verify::Symbol { name: symbol_name, library: library_name };- let mut attempts = 0;- let mut last_trace = Value::Null;- let mut last_mech = "post";- for attempt in 1..=2 {- attempts = attempt;- let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);- last_trace = trace;- last_mech = nav_mech;- if ok {- let title = title_of(frame);- let mut v = json!({- "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,- "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),- "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,- "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },- });- merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));- return v;- }- if budget.out() {- // Wiki #71: a navigation that ran against an editor that is up is not "starting".- let title = title_of(frame);- let mut v = json!({- "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,- "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,- "navigation": last_trace,- "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),- "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",- });- merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));- return v;- }- }- // Two background attempts did not confirm. Is the symbol even in the editor's world?- invalidate();- if first_of_kind(WindowKind::SymbolEditor).is_none() {- return json!({- "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",- "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),- "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",- });- }- let title = title_of(frame);- // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the- // libraries after the library file was last written, so its cache cannot be stale. Saying- // library_cache_stale there sends the caller to rescan:true, which only closes and reopens- // an already fresh editor and burns the budget again. The verdict is navigation_failed.- let on_disk = symbol_on_disk(info, symbol_name);- let editor_is_ours = mechanism != "existing";- let stale = on_disk && !rescan && !editor_is_ours;- if stale {- let ctx = lib_ctx(info);- let paths = libraries::adom_paths(&ctx);- return json!({- "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,- "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,- "symbolInLibraryFile": true,- "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),- "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),- "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),- "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",- });- }- let mut v = json!({- "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,- "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,- "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),- "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",- });- if on_disk && editor_is_ours {- v["symbolInLibraryFile"] = json!(true);- v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));- v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");- }- merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));- v-}--fn merge(into: &mut Value, extra: Value) {- if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {- for (k, v) in e {- o.insert(k.clone(), v.clone());- }- }-}--// ── kicad_open_footprint_editor ───────────────────────────────────────────────--pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if !info.installed {- return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");- }- let spec = &FOOTPRINT_SPEC;- let budget = Budget::new(args);- let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();- let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();- let mut pathways: Vec<String> = Vec::new();- invalidate();- if !footprint_name.is_empty() {- for w in windows_of_kind(WindowKind::FootprintEditor) {- if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {- let _ = native().show_no_activate(w.hwnd);- let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});- merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));- return v;- }- }- }- if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {- pathways.push("icon_click_not_in_native_build".into());- } else {- pathways.push("icon_click_skipped_background_mode".into());- }- let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {- Ok(x) => x,- Err(mut e) => {- if e["errorCode"] == json!(spec.error_code) {- e["error"] = json!("Could not open the Footprint Editor via the background path.");- e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");- }- return e;- }- };- if mechanism != "existing" {- sleep_ms(1500);- }- let _ = native().show_no_activate(frame);- if footprint_name.is_empty() && library_name.is_empty() {- return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});- }- if footprint_name.is_empty() {- let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);- return json!({- "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,- "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),- "via": "win32-library-browse",- "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",- });- }- let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };- let verify = Verify::Footprint { name: &footprint_name, library: &library_name };- let mut last_trace = Value::Null;- let mut last_mech = "post";- for attempt in 1..=2 {- let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);- last_trace = trace;- last_mech = nav_mech;- if ok {- let title = title_of(frame);- let mut v = json!({- "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,- "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),- "navigation": last_trace, "pathwaysTried": pathways,- });- merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));- return v;- }- if budget.out() {- return json!({- "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,- "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,- "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),- "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",- });- }- }- let title = title_of(frame);- let mut v = json!({- "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,- "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,- "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),- "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",- });- // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running- // KiCad may not have: no such library row, no such footprint in the library, or both are there and- // this KiCad simply started before the library was registered.- if !library_name.is_empty() {- let ctx = lib_ctx(&state.kicad_info());- let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));- match libs.first() {- None => {- v["errorCode"] = json!("library_not_registered");- v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));- v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");- }- Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {- v["errorCode"] = json!("footprint_not_in_library");- v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));- v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());- v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");- }- // Only a row in the user's OWN table can have been added after KiCad started. A stock- // library (reached through KiCad's nested template table) is loaded by every KiCad at- // start, so for those the failure is the navigation itself: keep navigation_failed- // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).- Some(_) if !user_table_row(&ctx, &library_name) => {}- Some((_, info)) => {- v["errorCode"] = json!("library_not_loaded_by_running_kicad");- v["libraryOnDisk"] = json!(info.path);- v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));- v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));- }- }- }- merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));- v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());-fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{- let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();- let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();- if exact.len()==1{return exact.into_iter().next()}- if exact.len()>1{return None}- if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}- let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();- if created.len()==1{return created.into_iter().next()}- if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}- None-}-fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{- for _ in 0..rounds.max(1){- sleep_ms(750);invalidate();- let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();- let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));- if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){- if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}- return Some(w)- }- }- None-}--/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a-/// 3D Viewer window appeared.-fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {- let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {- Some(w) => w.pid,- None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),- };- let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();- let mut unsupported_menu: Option<Value> = None;- match menu_invoke_by_text(frame, "3d viewer") {- Ok(Some((id, _))) => {- pathways.push(format!("win32menu(id={id})"));- if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {- return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));- }- }- Ok(None) => pathways.push("win32menu-not-found".into()),- Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),- Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),- }- match native().uia_invoke(frame, "3D Viewer") {- Ok(_) => {- pathways.push("uia".into());- if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {- return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));- }- }- Err(e) if is_not_impl(&e) => {- // Neither rung exists on this OS: say so instead of "did not open".- if let Some(u) = unsupported_menu {- return Err(u);- }- pathways.push("uia-unsupported".into());- }- Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),- }- Ok(None)-}---/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.--/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.-fn is_view_refresh(path: &[String]) -> bool {- let clean = |s: &str| {- s.replace('&', "")- .split('\t')- .next()- .unwrap_or("")- .trim()- .to_lowercase()- };- path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"-}--pub(crate) fn refresh_board_views(args: &Value) -> Value {- let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {- return fail(- "source_editor_required",- "Pass the exact pcbHwnd.",- "Read kicad_open_editors first.",- );- };- invalidate();- let windows = kicad_windows();- let Some(source) = windows- .iter()- .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)- else {- return fail(- "source_editor_missing",- "Requested PCB editor is not open.",- "No substitute was used.",- );- };- let viewers = windows_of_kind(WindowKind::Viewer3d);- let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();- let count = windows- .iter()- .filter(|w| {- w.pid == source.pid- && matches!(- w.kind(),- WindowKind::PcbEditor | WindowKind::FootprintEditor- )- })- .count();- let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {- m.get(&frame)- .filter(|(pid, _)| *pid == source.pid)- .map(|(_, h)| *h)- });- let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {- return fail(- "viewer_binding_ambiguous",- "No uniquely linked open 3D viewer.",- "Open the viewer from this exact PCB editor first; nothing was refreshed.",- );- };- let mut commands = Vec::new();- for hwnd in [frame, viewer.hwnd] {- let items = match native().menu_tree(hwnd) {- Ok(items) => items,- Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),- };- let matches: Vec<_> = items- .iter()- .filter(|m| m.id != 0 && is_view_refresh(&m.path))- .collect();- if matches.len() != 1 {- return fail(- "refresh_action_missing",- "Exact View > Refresh menu not uniquely available.",- "No fallback menu or keyboard action was invoked.",- );- }- commands.push((hwnd, matches[0].id));- }- let mut invoked = Vec::new();- for (hwnd, id) in commands {- if let Err(e) = native().menu_invoke(hwnd, id) {- return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});- }- invoked.push(hwnd);- }- invalidate();- json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})-}--pub(crate) fn menu_items(args: &Value) -> Value {- let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {- Some(h) => h,- None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),- };- let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();- match native().menu_tree(hwnd) {- Ok(items) => {- let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();- json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})- }- Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),- Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),- }-}--pub(crate) fn view_3d(args: &Value) -> Value {- const ACTIONS: &[(&str, &str)] = &[- ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),- ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),- ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),- ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),- ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),- ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),- ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),- ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),- ];- let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();- if action == "actions" || action.is_empty() {- return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});- }- invalidate();- let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {- Some(h) => h,- None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),- };- let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);- let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;- let steps: Vec<(String, usize)> = if action == "tour" {- match args.get("steps").and_then(Value::as_array) {- Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),- None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),- }- } else {- vec![(action.clone(), count)]- };- let mut done: Vec<Value> = Vec::new();- let started = Instant::now();- for (act, n) in &steps {- let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {- return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");- };- for _ in 0..*n {- match menu_invoke_by_text(hwnd, label) {- Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }- Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),- Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),- }- std::thread::sleep(Duration::from_millis(pause));- }- }- json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,- "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})-}--pub(crate) fn open_3d_viewer(args: &Value) -> Value {- let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();- if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {- return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");- }- invalidate();- let mut fp = first_of_kind(WindowKind::FootprintEditor);- let mut pcb = first_of_kind(WindowKind::PcbEditor);- // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd- // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.- if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {- pcb = Some(h);- }- if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {- fp = Some(h);- }- match choice.as_str() {- "fp" | "footprint" => {- if fp.is_none() {- return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");- }- pcb = None;- }- "pcb" | "board" => {- if pcb.is_none() {- return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");- }- fp = None;- }- _ => {- if pcb.is_some() && fp.is_some() {- fp = None; // auto: prefer PCB when both are open (tour-like flow)- }- }- }- let mut pathways: Vec<String> = Vec::new();- if let Some(f) = fp {- match open_3d_from(f, "Footprint Editor", &mut pathways) {- Ok(Some(v)) => return v,- Err(e) => return e,- Ok(None) => {}- }- if choice == "auto" && pcb.is_none() {- pcb = first_of_kind(WindowKind::PcbEditor);- }- if pcb.is_none() {- return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});- }- }- if let Some(p) = pcb {- match open_3d_from(p, "PCB Editor", &mut pathways) {- Ok(Some(v)) => return v,- Err(e) => return e,- Ok(None) => {}- }- return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});- }- fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")-}--fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {- keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)-}--// ── kicad_open_editors ────────────────────────────────────────────────────────--fn open_editors() -> Value {- let (wins, source) = match find_windows(false, true) {- Ok(x) => x,- Err(e) => return e,- };- let mut editors = Vec::new();- let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();- for w in &wins {- let kind = w.kind();- if !kind.is_editor() && kind != WindowKind::ProjectManager {- continue;- }- let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});- by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());- editors.push(row);- }- let running = !wins.is_empty();- let hint = if editors.is_empty() && !running {- "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."- } else {- "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."- };- json!({- "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,- "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},- "_hint": hint,- })-}--// ── kicad_close* ──────────────────────────────────────────────────────────────--fn close_kind(kind: WindowKind, label: &str) -> Value {- invalidate();- let wins = match find_windows(false, true) {- Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),- Err(e) => return e,- };- if wins.is_empty() {- return json!({"success": true, "output": format!("{label} is not open.")});- }- let mut closed = Vec::new();- let mut failed = Vec::new();- let mut hows = Vec::new();- for w in &wins {- let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));- hows.push(how);- if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }- }- if !failed.is_empty() {- let prompt = find_save_prompt();- let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");- if let Some(p) = prompt {- v["errorCode"] = json!("unsaved_changes");- v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);- v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");- }- return v;- }- json!({- "success": true, "closeVia": hows,- "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },- })-}--/// kicad_close {force:true}: the ONLY path that kills a process.-fn force_kill_kicad() -> Result<Vec<String>, Value> {- let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;- let mut killed = Vec::new();- for name in wm::KILL_PROCESS_NAMES {- let mut any = false;- for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {- if native().kill_process(p.pid).is_ok() {- any = true;- }- }- if any {- killed.push(name.to_string());- }- }- invalidate();- Ok(killed)-}--#[derive(Default)]-struct CloseState {- discard: bool,- closed: Vec<String>,- failed: Vec<String>,- unsaved: Vec<Value>,- discard_log: Vec<Value>,-}--/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or-/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.-fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {- let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));- if gone {- return Some(true);- }- let Some(prompt) = find_save_prompt() else { return Some(false) };- if st.discard {- let mut attempts = Vec::new();- let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {- Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }- Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }- };- if !pressed {- for label in wm::DISCARD_LABELS_UIA {- match native().uia_invoke(prompt.hwnd, label) {- Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }- Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),- }- }- }- st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));- if pressed {- let deadline = Instant::now() + Duration::from_secs(8);- while Instant::now() < deadline {- sleep_ms(300);- invalidate();- if !window_alive(w.hwnd) {- st.closed.push(format!("{} (changes discarded)", w.title));- return None;- }- }- }- }- st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));- Some(false)-}--fn close_kicad(_state: &mut State, args: &Value) -> Value {- let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);- let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);- // Wiki #48: a close is the last moment these windows exist, so learn where they are- // before the geometry is gone. A kill in particular denies KiCad its own save.- remember_placement();- if force {- return match force_kill_kicad() {- Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),- Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),- Err(e) => e,- };- }- invalidate();- let wins = match find_windows(false, true) {- Ok((w, _)) => w,- Err(e) => return e,- };- let mut editors = Vec::new();- let mut manager: Option<Win> = None;- for w in wins.into_iter().filter(|w| w.owner == 0) {- if wm::is_manager_title(&w.title) {- manager = Some(w);- } else {- editors.push(w);- }- }- if editors.is_empty() && manager.is_none() {- return json!({"success": true, "output": "KiCad is not running."});- }- // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with- // discardChanges press Discard and stay graceful; otherwise leave the window open and- // report unsavedChanges. Nothing is ever killed here.- let mut st = CloseState::default();- st.discard = discard;- for w in &editors {- match close_one(&mut st, w) {- Some(true) => st.closed.push(w.title.clone()),- Some(false) => st.failed.push(w.title.clone()),- None => {}- }- }- if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {- match close_one(&mut st, m) {- Some(true) => st.closed.push(m.title.clone()),- Some(false) => st.failed.push(m.title.clone()),- None => {}- }- }- let CloseState { closed, failed, unsaved, discard_log, .. } = st;- if !unsaved.is_empty() {- return json!({- "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,- "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),- "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },- "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",- "discard": discard_log,- });- }- if !failed.is_empty() {- // No save prompt and no close: the native build never kills here (force:true is- // the only kill path); say what did not close and how to force it.- // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.- // Report every dialog that is up (the unrecognised save prompt is among them) and- // the editor's own close verb.- let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();- let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]- .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();- return json!({- "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,- "error": format!("Failed to close: {}", failed.join(", ")),- "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },- "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",- });- }- let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});- if !discard_log.is_empty() {- out["discard"] = json!(discard_log);- }- out-}--// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────--fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {- let changed = force_software_canvas(&info.version);- return json!({- "success": true, "forcedSoftwareCanvas": changed,- "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },- "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",- });- }- if let Err(e) = find_windows(true, true) {- return e;- }- if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {- let wins = scan_dialogs();- let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();- return json!({- "success": true, "debug": true, "kicadPids": pids,- "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),- "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),- });- }- let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);- let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);- let res = sweep_dialogs(&info, all, screenshot);- let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);- let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);- let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);- json!({- "success": true,- "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),- "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,- "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),- })-}--fn window_info(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if let Err(e) = find_windows(true, true) {- return e;- }- let mut auto_dismissed = json!([]);- let mut forced_sw = false;- if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {- let s = sweep_dialogs(&info, false, false);- auto_dismissed = s["dismissed"].clone();- forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);- }- invalidate();- let (wins, source) = match find_windows(false, true) {- Ok(x) => x,- Err(e) => return e,- };- let mut editors = Vec::new();- let mut dialogs = Vec::new();- let mut manager = Value::Null;- for w in &wins {- let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});- if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {- let mut d = base;- d["ownerHwnd"] = json!(w.owner);- d["body"] = json!(dialog_body_text(w.hwnd));- dialogs.push(d);- } else if wm::is_manager_title(&w.title) {- manager = base;- } else {- editors.push(base);- }- }- let mut data = json!({- "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),- "autoDismissed": auto_dismissed, "windowSource": source,- });- if forced_sw {- data["forcedSoftwareCanvas"] = json!(true);- }- let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);- if n_auto > 0 {- data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));- }- if !dialogs.is_empty() {- data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");- }- json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})-}--// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────--/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.-fn canvas_probe(hwnd: u64) -> Value {- let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };- let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));- wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)-}--/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);-/// the canvas verdict is always ours.-pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {- let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {- Ok(shot) => shot.to_json(),- // Linux: KiCad's windows are X windows the bridge can read itself, so a screenshot does- // not depend on ab's desktop capture (which a Wayland desktop may not offer).- Err(e) if native().capabilities().os == "linux" => match local_screenshot(hwnd, label, resize_max) {- Ok(mut l) => {- l["abError"] = json!(e.message());- l- }- Err(le) => json!({"success": false, "error": format!("{}; local capture: {le}", e.message()), "abUnavailable": e.is_unavailable()}),- },- Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),- };- v["canvas"] = canvas_probe(hwnd);- v-}--/// Where the bridge writes the screenshots it takes itself: <data dir>/screenshots.-fn local_shot_dir() -> PathBuf {- std::env::var_os("ADOM_BRIDGE_DATA_DIR")- .filter(|d| !d.is_empty())- .map(PathBuf::from)- .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/adom-kicad-bridge")))- .unwrap_or_else(std::env::temp_dir)- .join("screenshots")-}--/// A window's own pixels to a PNG, with no ab round trip (the window does not move or focus).-/// The reply has the fields of ab's capture (savedTo, fullPath, safePath, coordMap) so every-/// caller reads it the same way.-fn local_screenshot(hwnd: u64, label: &str, resize_max: Option<u32>) -> Result<Value, String> {- let cap = native().capture_window(hwnd)?;- let rect = native().window_info(hwnd)?.map(|w| w.rect).unwrap_or((0, 0, cap.width as i32, cap.height as i32));- let dir = local_shot_dir();- let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);- let safe_label: String = label.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).take(60).collect();- let full = dir.join(format!("{stamp}-{safe_label}.png"));- let (fw, fh) = kicad_core::sch_render::save_rgb_png(cap.width, cap.height, &cap.rgb, None, &full)?;- let mut out = json!({- "success": true, "savedTo": norm_path(&full), "fullPath": norm_path(&full), "capturedBy": "local:x11",- "fullWidth": fw, "fullHeight": fh, "sizeKB": std::fs::metadata(&full).map(|m| m.len() / 1024).unwrap_or(0),- "coordMap": {"windowX": rect.0, "windowY": rect.1, "imageScale": 1.0, "note": "image pixel (px, py) is screen (windowX + px, windowY + py)"},- });- if let Some(m) = resize_max.filter(|m| *m > 0 && *m < cap.width) {- let small = dir.join(format!("{stamp}-{safe_label}-safe.png"));- let (sw, sh) = kicad_core::sch_render::save_rgb_png(cap.width, cap.height, &cap.rgb, Some(m), &small)?;- out["safePath"] = json!(norm_path(&small));- out["safeWidth"] = json!(sw);- out["safeHeight"] = json!(sh);- out["coordMap"]["safeScale"] = json!(sw as f64 / fw as f64);- }- Ok(out)-}--fn norm_path(p: &Path) -> String {- p.to_string_lossy().replace('\\', "/")+ v["canvas"] = canvas_probe(hwnd);+ v } fn screenshot_all(state: &mut State) -> Value { let info = window_info(state, &json!({}));- if info["success"] != json!(true) {- return info;- }- let data = &info["data"];- let mut windows: Vec<(u64, String, &str)> = Vec::new();- for ed in data["editors"].as_array().into_iter().flatten() {- windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));- }- if let Some(h) = data["projectManager"]["hwnd"].as_u64() {- windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));- }- for d in data["modalDialogs"].as_array().into_iter().flatten() {- windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));- }- if windows.is_empty() {- return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");- }- let mut shots = Vec::new();- let mut ab_unavailable = false;- for (hwnd, title, kind) in &windows {- let label = wm::safe_label(title, kind);- let r = screenshot_hwnd(*hwnd, &label, None);- let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});- if r["success"] == json!(true) {- for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {- if let Some(v) = r.get(k) {- entry[k] = v.clone();- }- }- } else {- entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));- if r["abUnavailable"] == json!(true) {- ab_unavailable = true;- }- }- shots.push(entry);- }- let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();- let mut out = json!({- "success": captured > 0 || !ab_unavailable,- "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),- "data": {"screenshots": shots},- "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",- });- if ab_unavailable {- out["abUnavailable"] = json!(true);- out["errorCode"] = json!("ab_unavailable");- out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");- }- out-}--// ── kicad_state ───────────────────────────────────────────────────────────────--pub(crate) fn b64_encode(bytes: &[u8]) -> String {- const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";- let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);- for chunk in bytes.chunks(3) {- let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];- let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;- out.push(T[(n >> 18) as usize & 63] as char);- out.push(T[(n >> 12) as usize & 63] as char);- out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });- out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });- }- out-}--fn kicad_state(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- let (wins, source) = match find_windows(false, true) {- Ok(x) => x,- Err(e) => return e,- };- let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);- let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;- let mut shots = Vec::new();- let mut ab_unavailable = false;- if include_images {- for w in &wins {- let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});- let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));- entry["canvas"] = r["canvas"].clone();- if r["success"] == json!(true) {- let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");- match std::fs::read(path) {- Ok(bytes) if path.to_lowercase().ends_with(".png") => {- entry["image"] = json!(b64_encode(&bytes));- entry["path"] = json!(path);- if let Some(c) = r.get("coordMap") {- if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {- entry["width"] = json!(w);- entry["height"] = json!(h);- }- }- if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }- if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }- }- Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }- Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }- }- } else {- entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));- if r["abUnavailable"] == json!(true) {- ab_unavailable = true;- }- }- shots.push(entry);- }- }- let editors = open_editors();- let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();- let loop_events = native().etiquette_events();- let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();- events.extend(loop_events);- let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();- let focus_debug = match native().etiquette_debug() {- Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),- v => v,- };- let mut out = json!({- "success": true, "running": !wins.is_empty(), "focusEvents": events,- // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this- // first and defers while the user is active (< 600 = touched in the last 10 min).- "userIdleSeconds": user_idle_seconds(),- "focusDebug": focus_debug,- "activeVersion": info.version,- "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),- "windowSource": source,- "shots": shots,- "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},- "modalDialogs": modal,- "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",- });- if ab_unavailable {- out["abUnavailable"] = json!(true);- }- out-}--// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────--fn foreground_kicad_hwnd() -> Option<u64> {- if let Ok(fg) = native().foreground() {- if fg != 0 && is_kicad_hwnd(fg) {- return Some(fg);- }- }- kicad_windows().first().map(|w| w.hwnd)-}--fn send_key(args: &Value) -> Value {- // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the- // target window's menu bar, fully background. The way the PCM dialog is opened.- if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {- let target = match arg_any_u64(args, &["hwnd"]) {- Some(h) => h,- None => match foreground_kicad_hwnd() {- Some(h) => h,- None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),- },- };- let title = title_of(target);- return match menu_invoke_by_text(target, needle) {- Ok(Some((id, label))) => json!({- "success": true, "mechanism": "menu",- "output": format!("Menu item '{label}' fired on '{title}' (background)"),- "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},- "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",- }),- Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),- Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),- Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),- };- }- let Some(key) = arg_str(args, "key").map(str::trim) else {- return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");- };- let target = match arg_any_u64(args, &["hwnd"]) {- Some(h) => h,- None => match foreground_kicad_hwnd() {- Some(h) => h,- None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),- },- };- let title = title_of(target);- // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.- let _ = native().show_no_activate(target);- sleep_ms(50);- let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);- if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {- // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's- // menu bar and fire WM_COMMAND, fully background.- match menu_invoke_by_accel(target, key) {- Ok(Some((id, label))) => {- return json!({- "success": true, "mechanism": "menu",- "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),- "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},- "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",- });- }- Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),- _ => {}- }- if allow_fg {- return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");- }- return json!({- "success": false, "errorCode": "foreground_required",- "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),- "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",- });- }- if allow_fg {- return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");- }- let (vk, ch) = match wm::vk_for_key(key) {- Some(vk) => (vk, None),- None => {- let mut chars = key.chars();- match (chars.next(), chars.next()) {- (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),- _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),- }- }- };- match native().post_key(target, vk, ch) {- Ok(()) => json!({- "success": true, "mechanism": "post",- "output": format!("Posted '{key}' to '{title}' (background)"),- "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},- "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",- }),- Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),- Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),- }-}--fn click(args: &Value) -> Value {- let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {- return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");- };- let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {- return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");- };- let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);- if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {- return json!({- "success": false, "errorCode": "bad_arg",- "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),- "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",- });- }- if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {- return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");- }- let info = match native().window_info(hwnd) {- Ok(Some(i)) => i,- Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),- Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),- };- let (w, h) = (info.rect.2, info.rect.3);- let _ = native().show_no_activate(hwnd);- sleep_ms(50);- let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };- match native().post_click(hwnd, cx, cy, false) {- Ok(()) => json!({- "success": true, "mechanism": "post",- "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),- "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},- "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",- }),- Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),- Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),- }-}--fn fix_keyboard() -> Value {- json!({- "success": true, "stuckKeys": [], "released": false,- "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",- "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",- })-}--// ── kicad_place_footprint ─────────────────────────────────────────────────────--const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");--fn pseudo_uuid() -> String {- // Not cryptographic: a KiCad item id only has to be unique within the board.- let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);- let mut bytes = [0u8; 16];- for b in bytes.iter_mut() {- x ^= x << 13;- x ^= x >> 7;- x ^= x << 17;- *b = (x & 0xff) as u8;- }- bytes[6] = (bytes[6] & 0x0f) | 0x40;- bytes[8] = (bytes[8] & 0x3f) | 0x80;- let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();- format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])-}--/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.-fn quote_first_layer(text: &str) -> String {- let Some(i) = text.find("(layer ") else { return text.to_string() };- let rest = &text[i + 7..];- let trimmed = rest.trim_start();- if trimmed.starts_with('"') {- return text.to_string();- }- let end = trimmed.find(')').unwrap_or(0);- let name = trimmed[..end].trim();- if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {- return text.to_string();- }- format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])-}--/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.-fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {- let i = fp_text.find("(layer \"")?;- let close = fp_text[i..].find(')')? + i + 1;- Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))-}--fn place_footprint(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if !info.installed {- return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");- }- let file_name = arg_str(args, "fileName").unwrap_or("");- let Some(content) = arg_str(args, "fileContent") else {- return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");- };- let mut fp_text = match kicad_core::install::b64_decode(content) {- Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),- Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),- };- // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper- // and quote the top-level layer token the splice relies on.- if fp_text.starts_with("(module") {- fp_text = format!("(footprint{}", &fp_text["(module".len()..]);- fp_text = quote_first_layer(&fp_text);- }- if !fp_text.starts_with("(footprint") {- return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");- }- let fp_name = arg_str(args, "footprintName").map(str::to_string)- .or_else(|| kicad_core::install::footprint_name_in(&fp_text))- .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))- .unwrap_or_else(|| "footprint".into());- let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);- let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);- let uid = pseudo_uuid();- let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {- return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");- };- let skel = BLANK_BOARD.trim_end();- let idx = skel.rfind(')').unwrap_or(skel.len());- let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);- let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));- let _ = std::fs::create_dir_all(&user_dir);- let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));- if let Err(e) = std::fs::write(&out_path, combined) {- return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");- }- let board_path = norm(&out_path);- let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);- let mut opened = false;- let mut extra = Map::new();- if open_it {- match kicad_exe(&info, "pcbnew") {- Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {- Ok((pid, _)) => {- opened = true;- extra.insert("pid".into(), json!(pid));- // Verify the frame by title with one deadline; the sweep runs after.- let stem = format!("adom-place-{fp_name}").to_lowercase();- let deadline = Instant::now() + Duration::from_secs(20);- while Instant::now() < deadline {- invalidate();- if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {- extra.insert("hwnd".into(), json!(w.hwnd));- extra.insert("title".into(), json!(w.title));- break;- }- sleep_ms(500);- }- }- Err(e) => {- if e["errorCode"] == json!("not_supported_on_platform") {- return e;- }- return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});- }- },- None => {}- }- }- let mut v = json!({- "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },- "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),- });- merge(&mut v, Value::Object(extra));- v-}--// ── kicad_enable_software_opengl ──────────────────────────────────────────────--const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";-const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];-const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];--fn enable_software_opengl(state: &mut State) -> Value {- if native().capabilities().os != "windows" {- return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});- }- let info = state.kicad_info();- let mut bins: Vec<PathBuf> = Vec::new();- for i in &info.installs {- let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });- if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {- b = b.join("bin");- }- if b.is_dir() && !bins.contains(&b) {- bins.push(b);- }- }- if bins.is_empty() {- return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});- }- eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");- let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {- Ok(d) => d,- Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),- };- let zip = match pcm::ZipFile::parse(&data) {- Ok(z) => z,- Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),- };- let mut enabled = Vec::new();- let mut errors = Vec::new();- for bd in &bins {- let mut ok = true;- for dll in MESA_DLLS {- let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {- errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));- ok = false;- break;- };- let bytes = match zip.read(entry) {- Ok(b) => b,- Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }- };- if let Err(e) = std::fs::write(bd.join(dll), bytes) {- errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });- ok = false;- break;- }- }- if !ok {- continue;- }- for exe in MESA_EXES {- if bd.join(exe).exists() {- let _ = std::fs::write(bd.join(format!("{exe}.local")), "");- }- }- enabled.push(norm(bd));- eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));- }- let ok = !enabled.is_empty();- if !enabled.is_empty() {- NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa- }- json!({- "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",- "_hint": if ok {- "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."- } else {- "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."- },- })-}--#[cfg(test)]-mod tests {- use super::*;- #[test]- fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){- let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};- assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);- assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());- assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());- assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);- assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());- assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);- }--- #[test]- fn footprint_splice_matches_python() {- let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";- let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();- assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));- let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";- let modern = format!("(footprint{}", &legacy["(module".len()..]);- let q = quote_first_layer(&modern);- assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));- assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");- assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());- let u = pseudo_uuid();- assert_eq!(u.len(), 36);- assert_eq!(&u[14..15], "4");- }-- #[test]- fn base64_round_trip() {- assert_eq!(b64_encode(b""), "");- assert_eq!(b64_encode(b"f"), "Zg==");- assert_eq!(b64_encode(b"fo"), "Zm8=");- assert_eq!(b64_encode(b"foo"), "Zm9v");- assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");- let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();- assert_eq!(back, b"\x89PNG\r\n\x1a\n");- }-- #[test]- fn stamp_and_foreground_args() {- let s = utc_stamp();- assert_eq!(s.len(), 15);- assert_eq!(&s[8..9], "-");- assert!(user_wants_foreground(&json!({"foreground": true})));- assert!(user_wants_foreground(&json!({"showToUser": true})));- assert!(user_wants_foreground(&json!({"bringToFront": true})));- assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));- assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));- assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);- }-- #[test]- fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {- let mut state = State::new();- assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());- assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());- let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();- assert_eq!(fk["success"], json!(true));- assert_eq!(fk["stuckKeys"], json!([]));- // Without window control (this host) the window verbs answer not_supported_on_platform- // or an ab-unavailable error, never a hang and never a fake success.- if !native().capabilities().window_control {- std::env::remove_var("ADOM_DIRECT_API_URL");- let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();- assert_eq!(r["success"], json!(false));- let code = r["errorCode"].as_str().unwrap_or("");- assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");- let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();- assert_eq!(r["success"], json!(false));- let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();- assert_eq!(r["success"], json!(false));- }- let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();- assert_eq!(r["errorCode"], json!("bad_arg"));- let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();- assert_eq!(r["errorCode"], json!("missing_arg"));- let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();- assert_eq!(r["errorCode"], json!("bad_arg"));- }-- #[test]- fn catalog_covers_the_python_window_group() {- let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();- for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {- assert!(names.contains(&n), "{n} missing from the catalog");- }- assert!(!names.contains(&"kicad_errors"));- for v in VERBS {- assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);- }- }-}---/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.-fn rescan_libraries(state: &mut State, args: &Value) -> Value {- let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);- if !force {- let mut refreshed: Vec<u64> = Vec::new();- for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {- if let Some(h) = first_of_kind(kind) {- if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {- refreshed.push(h);- }- }- }- if !refreshed.is_empty() {- // The tree reloads asynchronously; the Python waited 3 s before probing.- std::thread::sleep(std::time::Duration::from_secs(3));- invalidate();- let mut wanted: Vec<(String, String)> = Vec::new();- for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {- match spec {- Value::String(s) if s.contains(':') => {- let (lib, name) = s.split_once(':').unwrap();- wanted.push((lib.to_string(), name.to_string()));- }- Value::Object(o) => {- if let Some(name) = o.get("name").and_then(Value::as_str) {- wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));- }- }- _ => {}- }- }- let mut probed: Vec<Value> = Vec::new();- if !wanted.is_empty() {- let fp = first_of_kind(WindowKind::FootprintEditor);- for (lib, name) in &wanted {- let (ok, trace, _mech) = match fp {- Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),- None => (false, json!({}), "none"),- };- probed.push(json!({- "library": lib, "name": name, "visible": ok,- "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },- "navigation": trace,- }));- }- }- let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));- let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});- out["_hint"] = json!(if !wanted.is_empty() && !verified {- format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",- probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))- } else if !wanted.is_empty() {- "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()- } else {- "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()- });- post_verb(state, "kicad_rescan_libraries", args, &mut out, None);- return out;- }- }- // Restart path: the native build has no ownership ledger yet (phase 4), so a running- // KiCad is treated as the user's unless the caller confirms.- let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();- let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);- if !running.is_empty() && !confirm {- return json!({"success": false, "errorCode": "rescan_refused",- "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",- "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),- "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});- }- // Wiki #48: a fresh process places every window on the primary display, so the user's- // monitor choice is captured here, while the windows still exist, and restored below.- let remembered = remember_placement();- let mut close_how = "nothing was running";- let mut graceful_failure = Value::Null;- if !running.is_empty() {- // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;- // the kill is the fallback for a window that will not go.- let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));- close_how = "graceful";- if !closed["success"].as_bool().unwrap_or(false) {- if closed["errorCode"] == json!("unsaved_changes") {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,- "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});- }- // Keep WHY the graceful close failed: otherwise the kill overwrites the only- // evidence and the next person sees "force-kill" with no cause.- graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});- closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));- close_how = "force-kill after the graceful close failed";- if !closed["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});- }- }- std::thread::sleep(std::time::Duration::from_secs(4));- }- let launched = launch(state, &json!({}));- if !launched["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});- }- for _ in 0..80 {- std::thread::sleep(std::time::Duration::from_millis(750));- invalidate();- if !kicad_windows().is_empty() {- sleep_ms(1200); // let the fresh windows settle before moving them- let restored = restore_placement();- let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();- return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",- "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,- "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});- }- }- json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",- "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}---#[cfg(test)]-mod linked_refresh_tests {- #[test]- fn exact_view_refresh_excludes_plugins_and_partial_labels() {- let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();- assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));- for a in [- vec!["Tools", "Refresh Plugins"],- vec!["View", "Refresh All"],- vec!["Refresh"],- ] {- assert!(!super::is_view_refresh(&path(&a)));- }- }-}--/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is-/// no API to ask, and the callers fall back to the window title.-#[cfg(feature = "ipc")]-fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {- kicad_core::ipc::open_board_paths(timeout)-}--#[cfg(not(feature = "ipc"))]-fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {- Err("this build has the ipc feature off".into())-}--#[cfg(feature = "ipc")]-fn same_board_path(a: &str, b: &str) -> bool {- kicad_core::ipc::same_board_path(a, b)-}--#[cfg(not(feature = "ipc"))]-fn same_board_path(a: &str, b: &str) -> bool {- a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))-}--// ----------------------------------------------------------------------------// kicad_teardrops: KiCad's own Edit > Edit Teardrops-// -----------------------------------------------------------------------------/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or-/// None without the IPC API. Read straight from KiCad, not through another verb: this runs-/// under GUI_LOCK, which verbs::call would try to take again.-/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.-#[cfg(feature = "ipc")]-fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {- use kicad_core::{detect, ipc};- let info = state.kicad_info();- let ctx = ipc::Ctx {- kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),- config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),- };- let (text, _, _) = ipc::board_text(&ctx, args).ok()?;- Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))-}--#[cfg(not(feature = "ipc"))]-fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {- None-}--fn teardrops(state: &mut State, args: &Value) -> Value {- let Some(path) = arg_str(args, "filePath") else {- return fail("missing_arg", "filePath is required", "The .kicad_pcb open in the PCB editor.");- };- let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();- let Some(editor) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) else {- return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor.");- };- let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);- let remove = flag("remove", false);- let before = teardrop_count(state, args).map(|(n, _)| n);- match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {- Ok(Some(_)) => {}- Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),- Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),- }- let mut dialog = None;- for _ in 0..60 {- if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {- dialog = Some(d.hwnd);- break;- }- sleep_ms(250);- }- let Some(dlg) = dialog else {- return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");- };- let mut steps = Vec::new();- let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };- let options = [("PTH pads", flag("pthPads", true)), ("SMD pads", flag("smdPads", true)), ("Vias", flag("vias", true)), ("Track to track", flag("tJunctions", false)), ("Selected items only", flag("selectedOnly", false))];- let mut failed = None;- for (label, on) in options {- if let Err(e) = set(label, on) {- failed = Some(e);- break;- }- }- if failed.is_none() {- let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };- match native().uia_invoke(dlg, action) {- Ok(r) => steps.push(r),- Err(e) => failed = Some(e),- }- }- if let Some(e) = failed {- let _ = native().click_button(dlg, &["close"]);- return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");- }- if let Err(e) = native().click_button(dlg, &["apply and close"]) {- let _ = native().click_button(dlg, &["close"]);- return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");- }- steps.push("apply and close".into());- let started = Instant::now();- while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {- sleep_ms(250);- }- if window_alive(dlg) {- return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");- }- let after_read = teardrop_count(state, args);- let after = after_read.as_ref().map(|(n, _)| *n);- let revision = after_read.map(|(_, r)| r);- let delta = match (before, after) {- (Some(b), Some(a)) => Some(a as i64 - b as i64),- _ => None,- };- json!({- "success": true,- "output": match delta {- Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },- None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),- },- "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),- "mutated": delta.map(|d| d != 0),- "dialogSteps": steps,- "revision": revision,- "saved": false,- "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. Save with kicad_save_board, then kicad_run_drc: teardrops are copper areas and take part in DRC.",- })-}+ if info["success"] != json!(true) {
rust/crates/kicad-core/src/install.rs+1−2883@@ -1,2894 +1,12 @@-//! Installing libraries and parts, ported from the Python bridge's-//! `handlers/install_library.py`, `handlers/install_symbol.py`,-//! `handlers/install_footprint.py::handle_install_footprint`,-//! `handlers/install_library_bundle.py`, `adom_library.py::add_symbol` and-//! `heal_split`, `handlers/kicad_parse_check.py` (the verdict-on-text helpers),-//! `kicad_detect.ensure_win_user_config` (the lib-table self-heal),-//! `handlers/make_part_project.py` and `handlers/export_part.py`.-//!-//! Everything here is file work. The lib-table parser and writer, the KiCad path-//! variable expansion, the row-first Adom paths (wiki #71), the quote-aware symbol-//! extraction and KiCad's own verdict on a library come from `crate::libraries`;-//! the zip reader comes from `crate::pcm`. Only what those modules keep private-//! is copied here, each copy named after its source.-//!-//! Window-phase steps the Python did through the KiCad window (opening the Symbol-//! or Footprint Editor after an install, the stale-editor check, the bundle's-//! proof frame, the running-KiCad check, registering a generated project with the-//! focus guardian) are NOT done here and are reported as such, never faked.--use std::collections::BTreeMap;-use std::path::{Path, PathBuf};-use std::sync::atomic::{AtomicU64, Ordering};-use std::time::Duration;--use serde_json::{json, Map, Value};--use crate::libraries::{- self, expand_kicad_uri, extract_symbols_tolerant, extract_top_level_symbols, kicad_parser_verdict, list_symbols,- norm_path, write_atomic, LibCtx, LibEntry, LibTable, ADOM_FP_DIR_NAME, ADOM_FP_LIB_NAME, ADOM_LIB_FILE,- ADOM_LIB_NAME,-};--pub const ADOM_FP_LIB_DESCRIPTION: &str = "Adom Bridge - custom footprints";-const DEFAULT_DESCRIPTION: &str = "Installed by Adom Bridge";-const NOT_INSTALLED_HINT: &str =- "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.";-const PARSE_TIMEOUT: Duration = Duration::from_secs(60);--pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;-pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;--// ----------------------------------------------------------------------------// Small helpers (std only)-// -----------------------------------------------------------------------------static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);--/// A fresh, unique directory under the OS temp dir. Unique across threads of one-/// process (tests run in parallel) and across processes.-pub fn unique_temp_dir(prefix: &str) -> PathBuf {- let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);- let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);- std::env::temp_dir().join(format!("{prefix}{}-{nanos}-{n}", std::process::id()))-}--/// Standard-alphabet base64, the way `base64.b64decode` reads it: whitespace is-/// ignored, `=` padding is optional, any other non-alphabet byte is an error.-pub fn b64_decode(s: &str) -> Result<Vec<u8>, String> {- let mut out = Vec::with_capacity(s.len() * 3 / 4);- let mut buf: u32 = 0;- let mut bits: u32 = 0;- let mut padded = false;- for c in s.bytes() {- let v = match c {- b'A'..=b'Z' => c - b'A',- b'a'..=b'z' => c - b'a' + 26,- b'0'..=b'9' => c - b'0' + 52,- b'+' => 62,- b'/' => 63,- b'=' => {- padded = true;- continue;- }- b' ' | b'\n' | b'\r' | b'\t' => continue,- _ => return Err(format!("invalid base64 character {:?}", c as char)),- };- if padded {- return Err("base64 data after padding".into());- }- buf = (buf << 6) | v as u32;- bits += 6;- if bits >= 8 {- bits -= 8;- out.push((buf >> bits) as u8);- buf &= (1u32 << bits) - 1;- }- }- Ok(out)-}--/// `shutil.copytree` after an `rmtree`: the destination is replaced wholesale.-pub fn copy_tree(src: &Path, dst: &Path) -> Result<(), String> {- std::fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?;- for entry in std::fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))? {- let entry = entry.map_err(|e| e.to_string())?;- let from = entry.path();- let to = dst.join(entry.file_name());- if from.is_dir() {- copy_tree(&from, &to)?;- } else {- std::fs::copy(&from, &to).map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;- }- }- Ok(())-}--fn file_name(p: &Path) -> String {- p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()-}--fn file_stem(p: &Path) -> String {- p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()-}--fn has_ext(p: &Path, ext: &str) -> bool {- p.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(ext)).unwrap_or(false)-}--/// Python's `repr(float)` for the values these generators produce: an integral-/// value prints with `.0`, anything else with Rust's shortest round-trip form,-/// which matches Python's for doubles.-fn py_float(x: f64) -> String {- if x.fract() == 0.0 && x.abs() < 1e16 {- format!("{x:.1}")- } else {- format!("{x}")- }-}--/// Python's round(): correctly rounded on the float's exact decimal value, ties to-/// even (108.9125 -> 108.912). Rust's `{:.N}` formatting has the same semantics, so-/// go through it rather than the half-away-from-zero `f64::round`.-fn round_to(x: f64, places: i32) -> f64 {- format!("{:.*}", places.max(0) as usize, x).parse().unwrap_or(x)-}--/// Sorted `*.kicad_mod` paths in a directory.-fn kicad_mods(dir: &Path) -> Vec<PathBuf> {- let mut v: Vec<PathBuf> = std::fs::read_dir(dir)- .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_file() && has_ext(p, "kicad_mod")).collect())- .unwrap_or_default();- v.sort();- v-}--/// `(footprint "NAME"` -> NAME, the first occurrence anywhere in the text.-pub fn footprint_name_in(text: &str) -> Option<String> {- let mut pos = 0;- while let Some(i) = text[pos..].find("(footprint") {- let start = pos + i + "(footprint".len();- let rest = &text[start..];- let trimmed = rest.trim_start();- if trimmed.len() != rest.len() {- if let Some(q) = trimmed.strip_prefix('"') {- if let Some(end) = q.find('"') {- if end > 0 {- return Some(q[..end].to_string());- }- }- }- }- pos = start;- }- None-}--/// Offset of the paren that closes the list opening at `open` (quote-aware), or-/// None when it never closes.-fn matching_close(text: &str, open: usize) -> Option<usize> {- let mut depth: i32 = 0;- let mut in_str = false;- let mut esc = false;- for (i, ch) in text[open..].char_indices() {- if in_str {- if esc {- esc = false;- } else if ch == '\\' {- esc = true;- } else if ch == '"' {- in_str = false;- }- continue;- }- match ch {- '"' => in_str = true,- '(' => depth += 1,- ')' => {- depth -= 1;- if depth == 0 {- return Some(open + i);- }- }- _ => {}- }- }- None-}--/// Every `(model "PATH"` occurrence as (offset of the opening quote, PATH).-fn model_refs(text: &str) -> Vec<(usize, String)> {- let mut out = Vec::new();- let mut pos = 0;- while let Some(i) = text[pos..].find("(model") {- let after = pos + i + "(model".len();- let rest = &text[after..];- let trimmed = rest.trim_start();- if trimmed.len() != rest.len() {- if let Some(q) = trimmed.strip_prefix('"') {- let qpos = after + (rest.len() - trimmed.len());- if let Some(end) = q.find('"') {- out.push((qpos, q[..end].to_string()));- }- }- }- pos = after;- }- out-}--/// Python `_rewrite_model_refs`: every `(model "...")` whose BASENAME matches an-/// installed model file is pointed at the installed absolute path. Other refs-/// (stock `${KICAD*_3DMODEL_DIR}` ones) are left alone. Returns the count.-pub fn rewrite_model_refs(pretty_dir: &Path, installed_by_name: &BTreeMap<String, String>) -> usize {- let mut rewritten = 0;- for m in kicad_mods(pretty_dir) {- let Ok(text) = std::fs::read_to_string(&m) else { continue };- let mut new_text = String::with_capacity(text.len());- let mut last = 0;- for (qpos, reference) in model_refs(&text) {- let base = reference.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string();- let Some(dest) = installed_by_name.get(&base) else { continue };- rewritten += 1;- new_text.push_str(&text[last..qpos]);- new_text.push('"');- new_text.push_str(dest);- new_text.push('"');- last = qpos + 1 + reference.len() + 1;- }- new_text.push_str(&text[last..]);- if new_text != text {- let _ = std::fs::write(&m, new_text);- }- }- rewritten-}--/// Python `_add_model_to_footprint`: drop any existing `(model ...)` block and-/// insert a fresh reference before the footprint's closing paren.-pub fn add_model_to_footprint(footprint_path: &Path, model_path: &str) -> Result<(), String> {- let mut text = std::fs::read_to_string(footprint_path).map_err(|e| format!("cannot read {}: {e}", footprint_path.display()))?;- // Remove existing model blocks (quote-aware, back to front so offsets hold).- let mut opens: Vec<usize> = Vec::new();- let mut pos = 0;- while let Some(i) = text[pos..].find("(model") {- let at = pos + i;- let after = &text[at + "(model".len()..];- if after.starts_with(|c: char| c.is_ascii_whitespace()) {- opens.push(at);- }- pos = at + "(model".len();- }- for open in opens.into_iter().rev() {- if let Some(close) = matching_close(&text, open) {- let mut start = open;- while start > 0 && text.as_bytes()[start - 1].is_ascii_whitespace() {- start -= 1;- }- text.replace_range(start..close + 1, "");- }- }- let block = format!(- "\n (model \"{model_path}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )"- );- if let Some(idx) = text.rfind(')') {- text = format!("{}{}\n{}", &text[..idx], block, &text[idx..]);- }- std::fs::write(footprint_path, text).map_err(|e| format!("cannot write {}: {e}", footprint_path.display()))-}--// ----------------------------------------------------------------------------// Bootstrap of a never-launched KiCad's per-user config (lib-table self-heal)-// -----------------------------------------------------------------------------/// Python `kicad_detect.ensure_win_user_config`, the lib-table half. For each of-/// sym-lib-table and fp-lib-table under `config_dir`: a missing table is seeded-/// from KiCad's own template (`<base>/share/kicad/template/<name>`), else written-/// empty; an EXISTING table gets every template row it lacks merged in (v0.9.84-/// self-heal: earlier bootstraps could leave a user with no stock libraries).-/// User rows are never touched. Returns `{"sym-lib-table": {seeded, merged},-/// "fp-lib-table": {...}}`; a table that cannot be read or written carries `error`.-pub fn heal_lib_tables(config_dir: &Path, base_dir: Option<&str>) -> Value {- let mut out = Map::new();- let _ = std::fs::create_dir_all(config_dir);- let template = base_dir.map(|b| Path::new(b).join("share").join("kicad").join("template"));- for (fname, root) in [("sym-lib-table", "sym_lib_table"), ("fp-lib-table", "fp_lib_table")] {- let dest = config_dir.join(fname);- let mut rep = json!({"seeded": false, "merged": 0});- if !dest.exists() {- match libraries::ensure_lib_table(&dest, root, base_dir) {- Ok(()) => rep["seeded"] = json!(true),- Err(e) => rep["error"] = json!(e),- }- out.insert(fname.into(), rep);- continue;- }- let Some(src) = template.as_ref().map(|t| t.join(fname)).filter(|p| p.is_file()) else {- out.insert(fname.into(), rep);- continue;- };- let (mut user_t, tmpl_t) = match (LibTable::parse_file(&dest), LibTable::parse_file(&src)) {- (Ok(u), Ok(t)) => (u, t),- (Err(e), _) | (_, Err(e)) => {- rep["error"] = json!(e);- out.insert(fname.into(), rep);- continue;- }- };- let mut added = 0;- for e in tmpl_t.entries {- if !user_t.has_library(&e.name) {- user_t.entries.push(e);- added += 1;- }- }- if added > 0 {- match user_t.write_file(&dest) {- Ok(()) => rep["merged"] = json!(added),- Err(e) => rep["error"] = json!(e),- }- }- out.insert(fname.into(), rep);- } Value::Object(out) } -/// Create each MISSING global library table (sym-lib-table, fp-lib-table and, for KiCad 10,-/// design-block-lib-table) in `config_dir`, from KiCad's own template when it ships one, else-/// as an empty table. Without all of them KiCad 10 opens its modal "KiCad Setup" wizard ("some-/// of its configuration files are missing") on launch; a distro package's template may not-/// ship design-block-lib-table. Existing tables are never touched. Returns one line per table written.-pub fn ensure_global_tables(config_dir: &Path, base_dir: Option<&str>, version: &str) -> Vec<String> {- let mut out = Vec::new();- let kicad10 = crate::updates::version_tuple(version).first().copied().unwrap_or(0) >= 10;- for (name, root) in [("sym-lib-table", "sym_lib_table"), ("fp-lib-table", "fp_lib_table"), ("design-block-lib-table", "design_block_lib_table")] {- if name == "design-block-lib-table" && !kicad10 {- continue;- }- let dest = config_dir.join(name);- if dest.exists() {- continue;- }- out.push(match libraries::ensure_lib_table(&dest, root, base_dir) {- Ok(()) => format!("global {name} created (it was missing; KiCad's setup wizard opens without it)"),- Err(e) => format!("could not create the global {name}: {e}"),- });- }- out-}- /// The dirs KiCad creates on its first launch, created ahead of it so an install /// works on a never-launched KiCad: `<config>/`, `<user>/symbols`, /// `<user>/footprints`, then the lib tables seeded and healed. Only runs when the-/// config or user dir is missing, exactly like the Python's `if not config_dir or-/// not user_dir` bootstrap; returns the two dirs.-pub fn ensure_user_config(ctx: &LibCtx) -> Result<(PathBuf, PathBuf), String> {- let (Some(config), Some(user)) = (ctx.config_dir.clone(), ctx.user_dir.clone()) else {- return Err("KiCad config/user directory not found".into());- };- let fresh = !(config.is_dir() && user.is_dir());- // The two library folders are always ensured (idempotent, like the Python's mkdir- // exist_ok); the lib-table seed and heal run only on a fresh box.- std::fs::create_dir_all(&config).map_err(|e| format!("cannot create {}: {e}", config.display()))?;- std::fs::create_dir_all(user.join("symbols")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;- std::fs::create_dir_all(user.join("footprints")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;- if fresh {- heal_lib_tables(&config, ctx.base_dir.as_deref());- }- Ok((config, user))-}--// ----------------------------------------------------------------------------// KiCad's verdict on library TEXT (handlers/kicad_parse_check.py)-// -----------------------------------------------------------------------------/// Python `wrap_symbol_blocks`: symbol blocks as a minimal library.-pub fn wrap_symbol_blocks(blocks: &[&str]) -> String {- format!("(kicad_symbol_lib (version 20231120) (generator \"adom-parse-check\")\n{}\n)\n", blocks.join("\n"))-}--/// Python `verdict_text`: the verdict for library text that has not been written-/// anywhere yet (a temp file that is deleted afterwards).-pub fn verdict_text(cli: Option<&Path>, lib_text: &str) -> Value {- let Some(cli) = cli.filter(|c| c.is_file()) else {- return json!({"available": false, "ok": null, "symbols": [], "units": 0,- "error": "kicad-cli not found; KiCad's parser could not be asked"});- };- let dir = unique_temp_dir("adom-candidate-");- let _ = std::fs::create_dir_all(&dir);- let tmp = dir.join("candidate.kicad_sym");- if let Err(e) = std::fs::write(&tmp, lib_text) {- let _ = std::fs::remove_dir_all(&dir);- return json!({"available": true, "ok": false, "symbols": [], "units": 0, "error": format!("cannot stage candidate: {e}")});- }- let v = kicad_parser_verdict(Some(cli), &tmp, PARSE_TIMEOUT);- let _ = std::fs::remove_dir_all(&dir);- v-}--/// Python `refused_symbols`: which of these (name, block) symbols KiCad refuses-/// on their own. `{available, accepted[], refused{name: error}}`.-pub fn refused_symbols(cli: Option<&Path>, blocks: &[(String, String)]) -> Value {- if cli.filter(|c| c.is_file()).is_none() {- return json!({"available": false, "accepted": [], "refused": {}});- }- let mut accepted = Vec::new();- let mut refused = Map::new();- for (name, blk) in blocks {- let v = verdict_text(cli, &wrap_symbol_blocks(&[blk.as_str()]));- if v["ok"].as_bool().unwrap_or(false) {- accepted.push(name.clone());- } else {- let err = v["error"].as_str().filter(|s| !s.is_empty()).unwrap_or("refused");- refused.insert(name.clone(), json!(err));- }- }- json!({"available": true, "accepted": accepted, "refused": refused})-}--// ----------------------------------------------------------------------------// The library's direct children in order (adom_library._parse_library_nodes)-// -----------------------------------------------------------------------------/// One direct child of `(kicad_symbol_lib ...)`: a symbol (name set) or an-/// attribute such as `(version ...)` or `(embedded_fonts no)`.-#[derive(Clone, Debug)]-struct LibNode {- name: Option<String>,- block: String,-}--impl LibNode {- fn is_symbol(&self) -> bool {- self.name.is_some()- }-}--/// Copied from `crate::libraries::quote_aware_children` (private there): the-/// spans of the root's direct child lists. Errors on any imbalance.-fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {- let mut in_str = false;- let mut esc = false;- let mut depth: i32 = 0;- let mut root_open = None;- let mut root_close = None;- let mut child_start = None;- let mut children = Vec::new();- for (i, ch) in text.char_indices() {- if in_str {- if esc {- esc = false;- } else if ch == '\\' {- esc = true;- } else if ch == '"' {- in_str = false;- }- continue;- }- match ch {- '"' => in_str = true,- '(' => {- depth += 1;- if depth == 1 {- root_open = Some(i);- } else if depth == 2 && child_start.is_none() {- child_start = Some(i);- }- }- ')' => {- depth -= 1;- if depth == 1 {- if let Some(s) = child_start.take() {- children.push((s, i + 1));- }- } else if depth == 0 {- root_close = Some(i);- break;- } else if depth < 0 {- return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));- }- }- _ => {}- }- }- if in_str {- return Err("unterminated quoted string".into());- }- let open = root_open.ok_or_else(|| "no s-expression found".to_string())?;- let close = root_close.ok_or_else(|| "unbalanced s-expression: the top-level list never closes".to_string())?;- Ok((open, close, children))-}--/// Copied from `crate::libraries::symbol_block_name` (private there).-fn symbol_block_name(block: &str) -> Option<String> {- let t = block.trim_start().strip_prefix('(')?.trim_start();- let t = t.strip_prefix("symbol")?;- let t2 = t.trim_start();- if t2.len() == t.len() {- return None;- }- let t = t2.strip_prefix('"')?;- let mut out = String::new();- let mut chars = t.chars();- while let Some(c) = chars.next() {- match c {- '\\' => {- if let Some(n) = chars.next() {- out.push(n);- }- }- '"' => return Some(out),- _ => out.push(c),- }- }- None-}--/// Python `_parse_library_nodes`: the library's direct children in order.-/// A non-library root or trailing text after the closer is an error.-fn parse_library_nodes(text: &str) -> Result<Vec<LibNode>, String> {- let (open, close, children) = quote_aware_children(text)?;- let head = text[open + 1..].split_whitespace().next().unwrap_or("").trim_end_matches(')');- if head != "kicad_symbol_lib" {- return Err(format!("not a symbol library (top-level list is '{head}')"));- }- if !text[close + 1..].trim().is_empty() {- let tail: String = text[close + 1..].trim().chars().take(40).collect();- return Err(format!("trailing content after the library's closing paren: {tail:?}"));- }- Ok(children.into_iter().map(|(a, b)| LibNode { name: symbol_block_name(&text[a..b]), block: text[a..b].to_string() }).collect())-}--fn attr_head(block: &str) -> &str {- block.trim_start_matches(['(', ' ', '\t']).split_whitespace().next().unwrap_or("")-}--// ----------------------------------------------------------------------------// add_symbol (adom_library.add_symbol)-// -----------------------------------------------------------------------------/// Merge the symbol(s) of a whole `.kicad_sym` text into the Adom library that-/// KiCad reads (row first). Existing symbols of the same name are replaced in-/// place; new ones go after the last symbol (or after the header attrs of an-/// empty library). Two gates before any write: the rebuilt text must parse back-/// with at least as many nodes, and KiCad's own parser must accept first the-/// incoming symbols alone and then the merged library (no kicad-cli: no verdict,-/// said out loud). One-generation `Adom.kicad_sym.bak` beside the library.-///-/// Returns `{success, added[], lib_path, kicadParser}` or `{success:false, error,-/// errorCode?, kicadParser?, _hint?}`.-pub fn add_symbol(ctx: &LibCtx, symbol_text: &str) -> Value {- // Same bootstrap the Python does through kicad_detect.ensure_win_user_config: a- // never-launched KiCad has no config or user dir yet (issue #20, ADOMBASELINE 2026-07).- if let Err(e) = ensure_user_config(ctx) {- return json!({"success": false, "error": e});- }- let ensure = libraries::ensure_adom_library(ctx, true);- if let Some(err) = ensure.get("error").and_then(Value::as_str) {- return json!({"success": false, "error": err});- }- let Some((_table, lib_path)) = libraries::adom_paths(ctx) else {- return json!({"success": false, "error": "KiCad paths not found"});- };-- let incoming = match extract_top_level_symbols(symbol_text) {- Ok(v) => v,- Err(e) => return json!({"success": false, "error": format!("The provided .kicad_sym is not a valid symbol library: {e}")}),- };- if incoming.is_empty() {- return json!({"success": false, "error": "No symbols found in the provided file content"});- }-- let existing_text = match std::fs::read_to_string(&lib_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("cannot read {}: {e}", norm_path(&lib_path))}),- };- let nodes = match parse_library_nodes(&existing_text) {- Ok(n) => n,- Err(e) => {- return json!({"success": false,- "error": format!("REFUSING to merge: the existing library at {} is corrupt ({e}). Repair or restore it first - merging into a broken file can only spread the damage.", norm_path(&lib_path)),- "_hint": "kicad_lint_library {libraryPath} now reports structural balance; a .bak written by a previous merge sits beside the library if one exists."})- }- };-- let incoming_by_name: BTreeMap<&str, &str> = incoming.iter().map(|(n, b)| (n.as_str(), b.as_str())).collect();- let added_names: Vec<String> = incoming.iter().map(|(n, _)| n.clone()).collect();-- let mut out_nodes: Vec<LibNode> = Vec::with_capacity(nodes.len() + incoming.len());- let mut replaced: Vec<&str> = Vec::new();- let mut last_symbol_idx: Option<usize> = None;- for node in &nodes {- match node.name.as_deref().and_then(|n| incoming_by_name.get(n).map(|b| (n, *b))) {- Some((n, blk)) => {- out_nodes.push(LibNode { name: Some(n.to_string()), block: blk.to_string() });- replaced.push(n);- }- None => out_nodes.push(node.clone()),- }- if node.is_symbol() {- last_symbol_idx = Some(out_nodes.len() - 1);- }- }- let new_syms: Vec<LibNode> = incoming- .iter()- .filter(|(n, _)| !replaced.iter().any(|r| r == n))- .map(|(n, b)| LibNode { name: Some(n.clone()), block: b.clone() })- .collect();- let insert_at = match last_symbol_idx {- Some(i) => i + 1,- None => {- let mut at = 0;- for n in &out_nodes {- if !n.is_symbol() && matches!(attr_head(&n.block), "version" | "generator" | "generator_version") {- at += 1;- } else {- break;- }- }- at- }- };- let tail = out_nodes.split_off(insert_at);- out_nodes.extend(new_syms);- out_nodes.extend(tail);-- let mut new_text = String::from("(kicad_symbol_lib\n");- for n in &out_nodes {- new_text.push('\t');- new_text.push_str(n.block.trim());- new_text.push('\n');- }- new_text.push_str(")\n");-- // THE GATE (#54): never write a library that does not parse back clean.- match parse_library_nodes(&new_text) {- Err(e) => {- return json!({"success": false,- "error": format!("internal merge error: the rebuilt library failed its own balance check ({e}); the library on disk was NOT touched. This is a bridge bug - report it with the input file.")})- }- Ok(rebuilt) if rebuilt.len() < nodes.len() => {- return json!({"success": false,- "error": format!("internal merge error: the rebuilt library lost nodes ({} < {}); the library on disk was NOT touched. This is a bridge bug - report it.", rebuilt.len(), nodes.len())})- }- Ok(_) => {}- }-- // THE KICAD GATE (wiki #71): the incoming symbols alone, then the merge.- let cli = ctx.kicad_cli.as_deref();- let incoming_blocks: Vec<&str> = incoming.iter().map(|(_, b)| b.as_str()).collect();- let incoming_v = verdict_text(cli, &wrap_symbol_blocks(&incoming_blocks));- if incoming_v["available"].as_bool().unwrap_or(false) && !incoming_v["ok"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "kicad_refuses_symbol", "kicadParser": incoming_v,- "error": format!("KiCad's own parser refuses the uploaded symbol(s) {:?}: {}. The library on disk was NOT touched, because one refused symbol makes KiCad drop the whole library.", added_names, incoming_v["error"].as_str().unwrap_or("")),- "_hint": "Fix the .kicad_sym so `kicad-cli sym export svg` accepts it (common: bare `hide yes` inside (effects ...) instead of `(hide yes)`, or a pre-20231120 shape). kicad_lint_library reports KiCad's verdict as kicadParser."});- }- let merged_v = verdict_text(cli, &new_text);- if merged_v["available"].as_bool().unwrap_or(false) && !merged_v["ok"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "kicad_refuses_merged_library", "kicadParser": merged_v,- "error": format!("KiCad's parser accepts the upload alone but refuses the merged library: {}. Nothing was written. The existing library is probably already refused by KiCad; run kicad_adom_library_status (kicadParser) and kicad_adom_library_heal mode:prune to drop the culprit.", merged_v["error"].as_str().unwrap_or(""))});- }-- // One-generation backup beside the library, then the replace.- let _ = std::fs::write(lib_path.with_extension("kicad_sym.bak"), &existing_text);- if let Err(e) = write_atomic(&lib_path, new_text.as_bytes()) {- return json!({"success": false, "error": e});- }-- let parser = if merged_v["available"].as_bool().unwrap_or(false) {- json!({"available": true, "ok": true, "symbols": merged_v["symbols"], "units": merged_v["units"]})- } else {- json!({"available": false, "ok": null, "note": "kicad-cli not found; KiCad's verdict was not obtained"})- };- json!({"success": true, "added": added_names, "lib_path": norm_path(&lib_path), "kicadParser": parser})-}--// ----------------------------------------------------------------------------// kicad_install_library (handlers/install_library.py)-// -----------------------------------------------------------------------------fn s_arg(args: &Value, key: &str) -> String {- args.get(key).and_then(Value::as_str).unwrap_or("").to_string()-}--/// Python `_single_part_hint`: a one-part (or file-named) `.kicad_sym` handed to-/// install_library usually meant install_symbol (wiki #72). Say so, do not refuse.-pub fn single_part_hint(source: &Path, lib_name: &str) -> Option<Value> {- if source.is_dir() || !has_ext(source, "kicad_sym") {- return None;- }- let names = list_symbols(source);- if names.is_empty() || names.len() > 3 {- return None;- }- if names.len() == 1 || names.iter().any(|n| n == lib_name) {- let shown = names.join(", ");- return Some(json!({- "singlePartFile": true,- "symbolNames": names,- "_hint": format!(- "NOTE: this file holds {} symbol(s) ({shown}), so KiCad now has a whole library named '{lib_name}' containing only that part, and the shared Adom library was NOT touched. If you meant 'put this part into the Adom library', the verb is kicad_install_symbol '{{\"filePath\":\"{}\"}}' (then kicad_show_library Adom). Both are valid; only one matches what a shared-library caller usually means.",- names.len(), norm_path(source)),- }));- }- None-}--fn merge_into(target: &mut Value, extra: &Value) {- if let (Some(dst), Some(src)) = (target.as_object_mut(), extra.as_object()) {- for (k, v) in src {- dst.insert(k.clone(), v.clone());- }- }-}--/// `kicad_install_library {libraryPath, libraryType?: symbol|footprint, libraryName?,-/// description?, model3dPath?, modelsPath?}`.-pub fn install_library(ctx: &LibCtx, installed: bool, args: &Value) -> Value {- if !installed {- return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});- }- let lib_path = s_arg(args, "libraryPath");- let lib_type = args.get("libraryType").and_then(Value::as_str).unwrap_or("symbol").to_string();- if lib_path.is_empty() {- return json!({"success": false, "error": "No libraryPath specified"});- }- if lib_type == "footprint" {- return install_footprint_library(ctx, args);- }-- let mut lib_name = s_arg(args, "libraryName");- let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();- let source = PathBuf::from(&lib_path);- if !source.exists() {- return json!({"success": false, "error": format!("Library source not found: {lib_path}"),- "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});- }- if lib_name.is_empty() {- lib_name = file_stem(&source);- }- let wrong_tool = single_part_hint(&source, &lib_name);-- // wiki #71: ask KiCad's own parser before registering a .kicad_sym.- if source.is_file() && has_ext(&source, "kicad_sym") {- let v = kicad_parser_verdict(ctx.kicad_cli.as_deref(), &source, PARSE_TIMEOUT);- if v["available"].as_bool().unwrap_or(false) && !v["ok"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "kicad_refuses_library", "kicadParser": v.clone(),- "error": format!("KiCad's own parser refuses {}: {}. Not copied, not registered: KiCad would list this library empty.", file_name(&source), v["error"].as_str().unwrap_or("")),- "_hint": "Fix the file until `kicad-cli sym export svg` accepts it; kicad_lint_library shows the same verdict as kicadParser."});- }- }- if lib_type != "symbol" {- return json!({"success": false, "error": format!("Unknown library type: {lib_type}. Use 'symbol' or 'footprint'")});- }-- let (config_dir, user_dir) = match ensure_user_config(ctx) {- Ok(d) => d,- Err(_) => {- return json!({"success": false, "error": "KiCad config directory not found and could not be bootstrapped",- "_hint": "Run kicad_list_versions to confirm KiCad is installed."})- }- };-- let dest_dir = user_dir.join("symbols");- if let Err(e) = std::fs::create_dir_all(&dest_dir) {- return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&dest_dir))});- }- let dest = dest_dir.join(file_name(&source));- let copied = if source.is_dir() {- if dest.exists() {- let _ = std::fs::remove_dir_all(&dest);- }- copy_tree(&source, &dest)- } else {- std::fs::copy(&source, &dest).map(|_| ()).map_err(|e| format!("cannot copy to {}: {e}", norm_path(&dest)))- };- if let Err(e) = copied {- return json!({"success": false, "error": e});- }-- let table_path = config_dir.join("sym-lib-table");- let mut table = match LibTable::parse_file(&table_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),- };- let uri = norm_path(&dest);- if table.has_library(&lib_name) {- // Idempotent: an already-registered library is SUCCESS (the file was refreshed above).- let mut out = json!({"success": true, "alreadyRegistered": true,- "output": format!("Library '{lib_name}' already registered in sym-lib-table (file refreshed at {uri})."),- "_hint": format!("Library was already present: nothing to change. This is a successful no-op.{}",- wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default())});- if let Some(w) = &wrong_tool {- out["singlePartFile"] = w["singlePartFile"].clone();- out["symbolNames"] = w["symbolNames"].clone();- }- return out;- }- table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));- if let Err(e) = table.write_file(&table_path) {- return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});- }- let mut out = json!({- "success": true,- "output": format!("Library '{lib_name}' installed to {uri} and registered in sym-lib-table"),- "data": {"name": lib_name, "path": uri, "table": "sym-lib-table"},- "_hint": format!("Library '{lib_name}' is now registered. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path>\"}}'` BEFORE install to catch malformed files, outdated formats, or wrong-filetype-renamed-with-.kicad_sym surprises in ~100ms, much faster than discovering a broken library inside the GUI later. Any KiCad windows already open need to be closed + reopened to pick up the new library entry (sym-lib-table is read at startup).{}",- wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default()),- });- if let Some(w) = &wrong_tool {- out["singlePartFile"] = w["singlePartFile"].clone();- out["symbolNames"] = w["symbolNames"].clone();- }- out-}--/// Python `_install_footprint`: a `.kicad_mod` (wrapped in `<name>.pretty`) or a-/// whole `.pretty` dir into `<user>/footprints`, optional 3D models (one file via-/// `model3dPath`, or a directory via `modelsPath` / the bundle's `3dmodels/`-/// sibling, with the COPIED footprints' matching refs rewritten), registered in-/// fp-lib-table.-pub fn install_footprint_library(ctx: &LibCtx, args: &Value) -> Value {- let lib_path = s_arg(args, "libraryPath");- let mut lib_name = s_arg(args, "libraryName");- let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();- let model_3d_path = s_arg(args, "model3dPath");-- let source = PathBuf::from(&lib_path);- if !source.exists() {- return json!({"success": false, "error": format!("Footprint source not found: {lib_path}"),- "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});- }- let (config_dir, user_dir) = match ensure_user_config(ctx) {- Ok(d) => d,- Err(_) => {- return json!({"success": false, "error": "KiCad config/user directory not found and could not be bootstrapped",- "_hint": "Is KiCad installed? Run kicad_list_versions. If it's freshly installed and never launched, this bootstrap should have created %APPDATA%/kicad/<ver>/."})- }- };-- let footprints_dir = user_dir.join("footprints");- if let Err(e) = std::fs::create_dir_all(&footprints_dir) {- return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&footprints_dir))});- }-- let pretty_dir: PathBuf;- let mut dest_file: Option<PathBuf> = None;- if source.is_dir() && has_ext(&source, "pretty") {- if lib_name.is_empty() {- lib_name = file_stem(&source);- }- pretty_dir = footprints_dir.join(file_name(&source));- if pretty_dir.exists() {- let _ = std::fs::remove_dir_all(&pretty_dir);- }- if let Err(e) = copy_tree(&source, &pretty_dir) {- return json!({"success": false, "error": e});- }- } else if source.is_file() && has_ext(&source, "kicad_mod") {- if lib_name.is_empty() {- lib_name = file_stem(&source);- }- pretty_dir = footprints_dir.join(format!("{lib_name}.pretty"));- if let Err(e) = std::fs::create_dir_all(&pretty_dir) {- return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});- }- // KiCad requires the file name to match the footprint name inside it.- let fp_text = std::fs::read_to_string(&source).unwrap_or_default();- let correct_name = footprint_name_in(&fp_text).map(|n| format!("{n}.kicad_mod")).unwrap_or_else(|| file_name(&source));- let dest = pretty_dir.join(&correct_name);- if let Err(e) = std::fs::copy(&source, &dest) {- return json!({"success": false, "error": format!("cannot copy to {}: {e}", norm_path(&dest))});- }- dest_file = Some(dest);- } else {- return json!({"success": false,- "error": format!("Invalid footprint source: expected .kicad_mod file or .pretty directory, got {}", file_name(&source))});- }-- // Single 3D model (model3dPath).- let mut model_installed_path = String::new();- if !model_3d_path.is_empty() {- let model_source = PathBuf::from(&model_3d_path);- if model_source.exists() {- let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));- let _ = std::fs::create_dir_all(&models_dir);- let model_dest = models_dir.join(file_name(&model_source));- if std::fs::copy(&model_source, &model_dest).is_ok() {- model_installed_path = norm_path(&model_dest);- if let Some(dest) = &dest_file {- let _ = add_model_to_footprint(dest, &model_installed_path);- }- }- }- }-- // Bundle models: modelsPath, or the 3dmodels/ sibling of a source .pretty.- let mut models_installed = 0usize;- let mut refs_rewritten = 0usize;- let mut models_src = s_arg(args, "modelsPath");- if models_src.is_empty() && source.is_dir() && has_ext(&source, "pretty") {- if let Some(sibling) = source.parent().map(|p| p.join("3dmodels")).filter(|p| p.is_dir()) {- models_src = norm_path(&sibling);- }- }- if !models_src.is_empty() {- let models_src_dir = PathBuf::from(&models_src);- if models_src_dir.is_dir() {- let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));- let _ = std::fs::create_dir_all(&models_dir);- let mut files: Vec<PathBuf> =- std::fs::read_dir(&models_src_dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();- files.sort();- let mut installed_by_name = BTreeMap::new();- for mf in files {- if !mf.is_file() {- continue;- }- let dest_model = models_dir.join(file_name(&mf));- if std::fs::copy(&mf, &dest_model).is_ok() {- installed_by_name.insert(file_name(&mf), norm_path(&dest_model));- models_installed += 1;- }- }- if !installed_by_name.is_empty() {- refs_rewritten = rewrite_model_refs(&pretty_dir, &installed_by_name);- }- }- }-- // Register in fp-lib-table.- let table_path = config_dir.join("fp-lib-table");- let mut table = match LibTable::parse_file(&table_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),- };- let uri = norm_path(&pretty_dir);- let registered = if table.has_library(&lib_name) {- false- } else {- table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));- if let Err(e) = table.write_file(&table_path) {- return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});- }- true- };-- let mut output = format!(- "Footprint library '{lib_name}' installed to {uri}{}",- if registered { " and registered in fp-lib-table" } else { " (already registered)" }- );- let mut data = json!({"name": lib_name, "path": uri, "table": "fp-lib-table"});- if !model_installed_path.is_empty() {- data["model3dPath"] = json!(model_installed_path);- output.push_str(&format!(". 3D model installed to {model_installed_path}"));- }- if models_installed > 0 {- data["modelsInstalled"] = json!(models_installed);- data["modelRefsRewritten"] = json!(refs_rewritten);- output.push_str(&format!(- ". {models_installed} 3D model(s) installed to 3dmodels/{lib_name}.3dshapes; {refs_rewritten} footprint model ref(s) rewritten to the installed paths"- ));- }- json!({- "success": true,- "output": output,- "data": data,- "_hint": format!("Footprint library '{lib_name}' is now in fp-lib-table. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path-to-.pretty-dir>\"}}'` BEFORE install to validate each .kicad_mod inside in ~100ms (catches malformed files, outdated formats). Any open KiCad windows need to be closed + reopened to pick up the new fp-lib-table entry."),- })-}--// ----------------------------------------------------------------------------// kicad_install_symbol (handlers/install_symbol.py)-// -----------------------------------------------------------------------------/// The text of an upload: `filePath` on this machine, else base64 `fileContent`.-fn read_upload(args: &Value, what: &str) -> Result<(String, String), Value> {- let file_name_arg = s_arg(args, "fileName");- let file_content = s_arg(args, "fileContent");- let file_path = s_arg(args, "filePath").trim().to_string();- if file_path.is_empty() && file_name_arg.is_empty() {- return Err(json!({"success": false, "error": "No fileName specified"}));- }- if file_path.is_empty() && file_content.is_empty() {- return Err(json!({"success": false, "error": "No fileContent specified (or pass filePath)"}));- }- if !file_path.is_empty() {- let p = PathBuf::from(&file_path);- if !p.exists() {- return Err(json!({"success": false, "error": format!("filePath not found: {file_path}"),- "_hint": "Absolute path on THIS machine: push it first (ab send_files) if it lives elsewhere."}));- }- let text = std::fs::read_to_string(&p).map_err(|e| json!({"success": false, "error": format!("Failed to read filePath: {e}")}))?;- let name = if file_name_arg.is_empty() { file_name(&p) } else { file_name_arg };- return Ok((text, name));- }- let bytes = b64_decode(&file_content).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {e}")}))?;- let text = String::from_utf8(bytes).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {what} is not UTF-8: {e}")}))?;- Ok((text, file_name_arg))-}--/// `kicad_install_symbol {fileName, fileContent | filePath, symbolName?, openEditor?,-/// quietInstall?, libraryName?}`: merge into the Adom library KiCad reads.-/// The editor step is window-phase and not attempted; `editorOpened` is false.-pub fn install_symbol(ctx: &LibCtx, installed: bool, args: &Value) -> Value {- if !installed {- return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});- }- let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);- let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);- let (symbol_text, _name) = match read_upload(args, "symbol") {- Ok(t) => t,- Err(e) => return e,- };-- let result = add_symbol(ctx, &symbol_text);- if !result["success"].as_bool().unwrap_or(false) {- let mut out = json!({"success": false, "error": result["error"].as_str().unwrap_or("Unknown error")});- for k in ["errorCode", "kicadParser", "_hint"] {- if let Some(v) = result.get(k).filter(|v| !v.is_null()) {- out[k] = v.clone();- }- }- return out;- }- let added: Vec<String> = result["added"].as_array().map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();- let lib_path = result["lib_path"].as_str().unwrap_or("").to_string();-- let mut output_parts = vec![format!("Symbol(s) {} added to '{ADOM_LIB_NAME}' library", added.join(", "))];- if open_editor {- output_parts.push("Symbol Editor: not opened by this build (window control arrives in phase 3; call kicad_show_symbol)".into());- }- let requested_lib = s_arg(args, "libraryName").trim().to_string();- let ignored_lib = !requested_lib.is_empty() && requested_lib != ADOM_LIB_NAME;- if ignored_lib {- output_parts.push(format!(- "NOTE: libraryName '{requested_lib}' was ignored - every Adom symbol installs into the single global '{ADOM_LIB_NAME}' library"- ));- }- let mut out = json!({- "success": true,- "output": output_parts.join(" | "),- "kicadParser": result["kicadParser"],- "installedPath": lib_path,- "symbolNames": added,- "libraryName": ADOM_LIB_NAME,- "editorOpened": false,- });- if ignored_lib {- out["ignoredArgs"] = json!({"libraryName": requested_lib});- out["_hint"] = json!(format!(- "libraryName is not honoured by this verb: all Adom symbols live in '{ADOM_LIB_NAME}'. Drop the argument, or use kicad_install_library if you genuinely need a separate library file."- ));- }- out-}--// ----------------------------------------------------------------------------// kicad_install_footprint (handlers/install_footprint.py)-// -----------------------------------------------------------------------------/// Replace the first `(model "...")` path, or append a fresh model block before-/// the footprint's closing paren (Python's inline logic, byte for byte).-fn wire_model_into_footprint(footprint_text: &str, model_uri: &str) -> String {- if footprint_text.contains("(model ") {- if let Some((qpos, old)) = model_refs(footprint_text).into_iter().next() {- let mut t = String::with_capacity(footprint_text.len() + model_uri.len());- // Rewrite the head `(model "old"` as `(model "new"`.- let head_start = footprint_text[..qpos].rfind("(model").unwrap_or(qpos);- t.push_str(&footprint_text[..head_start]);- t.push_str(&format!("(model \"{model_uri}\""));- t.push_str(&footprint_text[qpos + 1 + old.len() + 1..]);- return t;- }- }- let body = footprint_text.trim_end();- let close = body.rfind(')').unwrap_or(body.len());- format!(- "{} (model \"{model_uri}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )\n)\n",- &body[..close]- )-}--/// The Adom.pretty KiCad reads: ROW FIRST (wiki #71). If fp-lib-table already-/// registers an Adom row with a literal uri, that directory; else-/// `<user>/footprints/Adom.pretty`. A uri with `${...}` is not expanded here-/// (the Python did not either).-pub fn adom_pretty_dir(config_dir: &Path, user_dir: &Path) -> PathBuf {- let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);- if let Ok(t) = LibTable::parse_file(&config_dir.join("fp-lib-table")) {- if let Some(row) = t.get(ADOM_FP_LIB_NAME) {- if !row.uri.is_empty() && !row.uri.contains("${") {- return PathBuf::from(row.uri.replace('\\', "/"));- }- }- }- default-}--/// `kicad_install_footprint {fileName, fileContent | filePath, footprintName?,-/// openEditor?, quietInstall?, model?: {fileName, fileContent} | {filePath}}`.-/// The editor step and the stale-editor check are window-phase and not attempted.-pub fn install_footprint(ctx: &LibCtx, installed: bool, args: &Value) -> Value {- if !installed {- return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});- }- let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);- let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);- let (mut footprint_text, file_name_arg) = match read_upload(args, "footprint") {- Ok(t) => t,- Err(e) => return e,- };- let mut footprint_name = s_arg(args, "footprintName");- if footprint_name.is_empty() {- footprint_name = footprint_name_in(&footprint_text).unwrap_or_else(|| file_stem(Path::new(&file_name_arg)));- }-- let (config_dir, user_dir) = match ensure_user_config(ctx) {- Ok(d) => d,- Err(_) => return json!({"success": false, "error": "KiCad user/config directory not found (bootstrap failed)"}),- };- let fp_dir = user_dir.join("footprints");- let _ = std::fs::create_dir_all(&fp_dir);- let pretty_dir = adom_pretty_dir(&config_dir, &user_dir);- if let Err(e) = std::fs::create_dir_all(&pretty_dir) {- return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});- }-- // Optional 3D model: install + wire into the footprint.- let model = args.get("model").cloned().unwrap_or(Value::Null);- let mut model_installed = String::new();- let model_src_path = s_arg(&model, "filePath").trim().to_string();- let has_inline = !s_arg(&model, "fileName").is_empty() && !s_arg(&model, "fileContent").is_empty();- if !model_src_path.is_empty() || has_inline {- let (model_bytes, model_file_name) = if !model_src_path.is_empty() {- let mp = PathBuf::from(&model_src_path);- if !mp.exists() {- return json!({"success": false, "error": format!("model.filePath not found: {model_src_path}"),- "_hint": "Absolute path on THIS machine: push it first (ab send_files)."});- }- match std::fs::read(&mp) {- Ok(b) => (b, file_name(&mp)),- Err(e) => return json!({"success": false, "error": format!("Failed to read model.filePath: {e}")}),- }- } else {- match b64_decode(&s_arg(&model, "fileContent")) {- Ok(b) => (b, s_arg(&model, "fileName")),- Err(e) => return json!({"success": false, "error": format!("Failed to decode model.fileContent: {e}")}),- }- };- let models_dir = user_dir.join("3dmodels").join("Adom");- let _ = std::fs::create_dir_all(&models_dir);- let model_path = models_dir.join(file_name(Path::new(&model_file_name)));- if let Err(e) = std::fs::write(&model_path, &model_bytes) {- return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&model_path))});- }- model_installed = norm_path(&model_path);- footprint_text = wire_model_into_footprint(&footprint_text, &model_installed);- }-- // The file must be named after the footprint inside it.- let dest_file = pretty_dir.join(format!("{footprint_name}.kicad_mod"));- let existed_before = dest_file.exists();- // Identical content: leave the file alone (an mtime bump past KiCad's cache- // load makes the opener report a stale cache on every tour after the first).- let unchanged = existed_before && std::fs::read_to_string(&dest_file).map(|t| t == footprint_text).unwrap_or(false);- if !unchanged {- if let Err(e) = std::fs::write(&dest_file, &footprint_text) {- return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&dest_file))});- }- }-- // Register Adom in fp-lib-table.- let table_path = config_dir.join("fp-lib-table");- let mut table = match LibTable::parse_file(&table_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),- };- let mut registered = false;- if !table.has_library(ADOM_FP_LIB_NAME) {- table.add_library(LibEntry::new(ADOM_FP_LIB_NAME, "KiCad", &norm_path(&pretty_dir), "", ADOM_FP_LIB_DESCRIPTION));- if let Err(e) = table.write_file(&table_path) {- return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});- }- registered = true;- }-- let mut output_parts = vec![format!("Footprint '{footprint_name}' installed to '{ADOM_FP_LIB_NAME}' library")];- if registered {- output_parts.push("registered in fp-lib-table".into());- }- if !model_installed.is_empty() {- output_parts.push(format!("3D model installed + referenced: {model_installed}"));- }- if open_editor {- output_parts.push("Footprint Editor: not opened by this build (window control arrives in phase 3; call kicad_show_footprint)".into());- }- json!({- "success": true,- "output": output_parts.join(" | "),- "installedPath": norm_path(&dest_file),- "existedBefore": existed_before,- "unchanged": unchanged,- "footprintName": footprint_name,- "libraryName": ADOM_FP_LIB_NAME,- "editorOpened": false,- "model3d": if model_installed.is_empty() { Value::Null } else { json!(model_installed) },- })-}--// ----------------------------------------------------------------------------// kicad_install_library_bundle (handlers/install_library_bundle.py)-// -----------------------------------------------------------------------------fn name_ok(name: &str) -> bool {- // ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$- let mut chars = name.chars();- let Some(first) = chars.next() else { return false };- if !first.is_ascii_alphanumeric() {- return false;- }- let rest: Vec<char> = chars.collect();- rest.len() <= 63 && rest.iter().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-'))-}--/// Python `_safe_extract`: unpack with a zip-slip guard and an unpacked-size cap.-/// Returns the number of files extracted.-pub fn safe_extract(zip_bytes: &[u8], dest: &Path) -> Result<usize, String> {- let zf = crate::pcm::ZipFile::parse(zip_bytes)?;- let mut total: u64 = 0;- let mut count = 0usize;- for e in &zf.entries {- let name = e.name.as_str();- if name.is_empty() || name.ends_with('/') {- continue;- }- let norm = name.replace('\\', "/");- let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';- if norm.starts_with('/') || drive || norm.split('/').any(|seg| seg == "..") {- return Err(format!("unsafe zip entry: {name}"));- }- total += e.size;- if total > MAX_UNPACKED_BYTES {- return Err("bundle unpacks past the size cap".into());- }- let target = dest.join(&norm);- if let Some(parent) = target.parent() {- std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;- }- let data = zf.read(e)?;- std::fs::write(&target, data).map_err(|e| format!("cannot write {}: {e}", target.display()))?;- count += 1;- }- Ok(count)-}--fn walk_files(root: &Path, out: &mut Vec<PathBuf>) {- if let Ok(rd) = std::fs::read_dir(root) {- for e in rd.flatten() {- let p = e.path();- if p.is_dir() {- walk_files(&p, out);- } else {- out.push(p);- }- }- }-}--/// Python `_find_bundle_parts`: the shallowest `*.kicad_sym`, its sibling-/// `*.pretty` dir (first, sorted) and a sibling `3dmodels/` dir.-pub fn find_bundle_parts(root: &Path) -> Option<(PathBuf, Option<PathBuf>, Option<PathBuf>)> {- let mut all = Vec::new();- walk_files(root, &mut all);- let mut syms: Vec<PathBuf> = all.into_iter().filter(|p| has_ext(p, "kicad_sym")).collect();- syms.sort_by_key(|p| (p.components().count(), norm_path(p)));- let sym = syms.into_iter().next()?;- let base = sym.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());- let mut dirs: Vec<PathBuf> =- std::fs::read_dir(&base).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect()).unwrap_or_default();- dirs.sort();- let pretty = dirs.into_iter().find(|d| has_ext(d, "pretty"));- let models = base.join("3dmodels");- Some((sym, pretty, if models.is_dir() { Some(models) } else { None }))-}--/// Python `_top_symbol_names`: every `(symbol "NAME"` anywhere in the file, minus-/// `_N_N` sub-units (a whole-text scan, unlike the line-anchored list_symbols).-pub fn top_symbol_names(sym_path: &Path) -> Vec<String> {- let Ok(text) = std::fs::read_to_string(sym_path) else { return Vec::new() };- let mut names = Vec::new();- let mut pos = 0;- while let Some(i) = text[pos..].find("(symbol") {- let after = pos + i + "(symbol".len();- let rest = &text[after..];- let trimmed = rest.trim_start();- if trimmed.len() != rest.len() {- if let Some(q) = trimmed.strip_prefix('"') {- if let Some(end) = q.find('"') {- let name = &q[..end];- if !libraries::is_subunit_name(name) {- names.push(name.to_string());- }- }- }- }- pos = after;- }- names-}--/// `kicad_install_library_bundle {zipPath, libraryName?, capture?}`.-/// The proof frame (`capture:true`) and the running-KiCad check are window-phase:-/// `kicadRunning` is null, `restartNeeded` true, `proofSkipped` says why.-pub fn install_library_bundle(ctx: &LibCtx, installed: bool, args: &Value) -> Value {- if !installed {- return json!({"success": false, "error": "KiCad not installed", "_hint": "Install KiCad first (kicad_upgrade can do it), then retry."});- }- let zip_path = s_arg(args, "zipPath");- if zip_path.is_empty() {- return json!({"success": false, "error": "No zipPath specified",- "_hint": "Stage the bundle zip with write_file / write_file_chunk first, then pass its path."});- }- let source = PathBuf::from(&zip_path);- if !source.is_file() {- return json!({"success": false, "error": format!("Bundle zip not found: {zip_path}"),- "_hint": "The path must exist on THIS machine. Stage it with write_file / write_file_chunk first."});- }- if std::fs::metadata(&source).map(|m| m.len()).unwrap_or(0) > MAX_ZIP_BYTES {- return json!({"success": false, "error": "Bundle zip is larger than the 256MB cap"});- }- let lib_name = {- let n = s_arg(args, "libraryName");- if n.is_empty() { file_stem(&source) } else { n }.trim().to_string()- };- if !name_ok(&lib_name) {- return json!({"success": false, "error": format!("Library name not usable as a KiCad library id: {lib_name:?}"),- "_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."});- }-- let extract_dir = unique_temp_dir("adom-lib-bundle-");- let _ = std::fs::create_dir_all(&extract_dir);- let result = bundle_inner(ctx, args, &source, &lib_name, &extract_dir);- let _ = std::fs::remove_dir_all(&extract_dir);- result-}--fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extract_dir: &Path) -> Value {- let bytes = match std::fs::read(source) {- Ok(b) => b,- Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),- };- let extracted = match safe_extract(&bytes, extract_dir) {- Ok(n) => n,- Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),- };- if extracted == 0 {- return json!({"success": false, "error": "The bundle zip is empty"});- }- let Some((sym, pretty, models)) = find_bundle_parts(extract_dir) else {- return json!({"success": false, "error": "No .kicad_sym found in the bundle",- "_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."});- };- let capture = args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false);- if args.get("planOnly") == Some(&json!(true)) {- // wiki #106: the listing only, nothing installed, no lib-table writes- let mut plan = bundle_plan(&sym, pretty.as_deref(), models.as_deref(), capture);- plan["libraryName"] = json!(lib_name);- return json!({"success": true, "planOnly": true, "plan": plan, "mutated": false,- "_hint": "Nothing installed. Lay out the progress from plan.steps (the real run answers steps[] with the same ids) and the evidence slots from plan.expectedEvidence; then call again without planOnly."});- }- let started = std::time::Instant::now();- let mut done: Vec<Value> = vec![json!({"id": "unpack", "done": true, "elapsedMs": started.elapsed().as_millis() as u64})];- let top_names = top_symbol_names(&sym);- let mut report = json!({"libraryName": lib_name, "symbols": top_names.len(),- "footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0});-- let sym_res = install_library(ctx, true, &json!({- "libraryPath": norm_path(&sym), "libraryType": "symbol", "libraryName": lib_name,- "description": "Adom wiki Cloud Library",- }));- if !sym_res["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "error": format!("Symbol install failed: {}", sym_res["error"].as_str().unwrap_or("")),- "step": "symbols", "detail": sym_res, "steps": done});- }- done.push(json!({"id": "symbols", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));- if let Some(pretty) = &pretty {- let fp_res = install_footprint_library(ctx, &json!({- "libraryPath": norm_path(pretty), "libraryName": lib_name,- "modelsPath": models.as_ref().map(|m| norm_path(m)).unwrap_or_default(),- "description": "Adom wiki Cloud Library",- }));- if !fp_res["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "error": format!("Footprint install failed: {}", fp_res["error"].as_str().unwrap_or("")),- "step": "footprints", "detail": fp_res, "symbolsInstalled": true});- }- report["footprints"] = json!(kicad_mods(pretty).len());- report["modelsInstalled"] = fp_res["data"].get("modelsInstalled").cloned().unwrap_or(json!(0));- report["modelRefsRewritten"] = fp_res["data"].get("modelRefsRewritten").cloned().unwrap_or(json!(0));- done.push(json!({"id": "footprints", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));- if models.is_some() {- done.push(json!({"id": "models", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));- }- }- done.push(json!({"id": "register", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));- report["steps"] = json!(done);-- // Whether the user has to restart KiCad is a fact about their machine, not a guess:- // the process scan answers it where the OS layer has one (wiki #63).- let (running, restart, why_unknown, pids) = crate::install_kicad::running_report();- report["kicadRunning"] = running.clone();- report["restartNeeded"] = json!(restart);- if let Some(why) = &why_unknown {- report["kicadRunningUnknownBecause"] = json!(why);- }- if !pids.is_empty() {- report["kicadPids"] = json!(pids);- }-- let mut parts = vec![format!("Library '{lib_name}' installed: {} symbols", report["symbols"])];- if report["footprints"].as_u64().unwrap_or(0) > 0 {- parts.push(format!("{} footprints", report["footprints"]));- }- if report["modelsInstalled"].as_u64().unwrap_or(0) > 0 {- parts.push(format!("{} 3D models", report["modelsInstalled"]));- }- let restart_line = crate::install_kicad::restart_sentence(&running, &pids, "the library");- let summary = parts.join(", ") + ". " + &restart_line;-- let mut result = json!({- "success": true,- "output": summary,- "data": report,- "_hint": format!("The bundle is installed and registered in both lib tables. {restart_line}"),- });- if capture {- result["proofSkipped"] = json!("capture is window-phase: this build opens no Symbol Editor and takes no frame; call kicad_show_library once the window verbs land");- }- result-}--/// The plan for a bundle install (wiki #106): the steps the real run reports with the same ids, a-/// rough time for each, and per part the evidence the install produces: its symbol always, its-/// footprint when the bundle's .pretty has the symbol's footprint, 3d when that footprint's model is-/// in the bundle. One library frame at the end (the Symbol Editor), only with capture: never one-/// image per part.-pub fn bundle_plan(sym: &Path, pretty: Option<&Path>, models: Option<&Path>, capture: bool) -> Value {- let text = std::fs::read_to_string(sym).unwrap_or_default();- let fps: Vec<PathBuf> = pretty.map(kicad_mods).unwrap_or_default();- let model_files: Vec<String> = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_lowercase())).collect()).unwrap_or_default();- let model_bytes: u64 = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.metadata().ok().map(|m| m.len())).sum()).unwrap_or(0);- let fp_text = |name: &str| fps.iter().find(|p| p.file_stem().map(|s| s.to_string_lossy() == name).unwrap_or(false)).and_then(|p| std::fs::read_to_string(p).ok());- let mut evidence = Vec::new();- if let Ok(root) = crate::sxedit::parse(&text) {- for sy in root.kids_tagged("symbol") {- let Some(name) = sy.atom(1) else { continue };- let fp_prop = sy.kids_tagged("property").find(|p| p.atom(1) == Some("Footprint")).and_then(|p| p.atom(2)).unwrap_or("");- let fp_name = fp_prop.rsplit(':').next().unwrap_or("");- let mut slots = vec!["symbol"];- if let Some(ft) = (!fp_name.is_empty()).then(|| fp_text(fp_name)).flatten() {- slots.push("footprint");- let has_model = ft.match_indices("(model ").any(|(i, _)| {- let rest = &ft[i + 7..];- let path = rest.trim_start().trim_start_matches('"').split('"').next().unwrap_or("");- let stem = path.rsplit(['/', '\\']).next().unwrap_or("").rsplit_once('.').map(|x| x.0).unwrap_or("").to_lowercase();- !stem.is_empty() && model_files.contains(&stem)- });- if has_model {- slots.push("3d");- }- }- evidence.push(json!({"part": name, "footprint": fp_prop, "slots": slots}));- }- }- let n = evidence.len() as f64;- let mut steps = vec![json!({"id": "unpack", "label": "Unpack the bundle", "etaSec": 1})];- steps.push(json!({"id": "symbols", "label": format!("Install {} symbol(s)", evidence.len()), "etaSec": (1.0 + n * 0.05).ceil()}));- if !fps.is_empty() {- steps.push(json!({"id": "footprints", "label": format!("Install {} footprint(s)", fps.len()), "etaSec": (1.0 + fps.len() as f64 * 0.05).ceil()}));- }- if !model_files.is_empty() {- steps.push(json!({"id": "models", "label": format!("Carry {} 3D model(s)", model_files.len()), "etaSec": (1.0 + model_bytes as f64 / 50e6).ceil()}));- }- steps.push(json!({"id": "register", "label": "Register both library tables", "etaSec": 1}));- if capture {- steps.push(json!({"id": "frame", "label": "One frame of the library in the Symbol Editor", "etaSec": 15}));- }- let total: f64 = steps.iter().filter_map(|s| s["etaSec"].as_f64()).sum();- json!({"steps": steps, "totalEtaSec": total, "expectedEvidence": evidence, "libraryFrame": if capture { 1 } else { 0 },- "counts": {"symbols": evidence.len(), "footprints": fps.len(), "models": model_files.len()}})-}--// ----------------------------------------------------------------------------// kicad_adom_library_heal (adom_library.heal_split)-// -----------------------------------------------------------------------------/// Reconcile a split Adom library (wiki #71). `merge` (default) copies every-/// top-level symbol the orphan has and the registered file lacks into the-/// registered file through `add_symbol` (balance gate, .bak); `repoint` rewrites-/// the row's uri to the orphan; `prune` asks KiCad about each symbol alone and-/// rebuilds the registered file from the accepted ones (`.pruned.bak` kept).-/// `dry_run` previews. Human-chosen: the bridge cannot know which file is real.-pub fn heal_split(ctx: &LibCtx, mode: &str, dry_run: bool) -> Value {- let rep = libraries::library_split_report(ctx);- if mode == "prune" {- let reg = rep["registeredPath"].as_str().unwrap_or("").to_string();- if reg.is_empty() || !Path::new(®).exists() {- return json!({"success": false, "error": "no registered Adom library file to prune", "report": rep});- }- let text = match std::fs::read_to_string(®) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("cannot read {reg}: {e}")}),- };- let cli = ctx.kicad_cli.as_deref();- let whole = kicad_parser_verdict(cli, Path::new(®), PARSE_TIMEOUT);- if !whole["available"].as_bool().unwrap_or(false) {- return json!({"success": false, "error": whole["error"], "kicadParser": whole});- }- let (blocks, notes) = extract_symbols_tolerant(&text);- let names: Vec<&str> = blocks.iter().map(|(n, _)| n.as_str()).collect();- if whole["ok"].as_bool().unwrap_or(false) {- return json!({"success": true, "healed": false, "mode": "prune", "dryRun": dry_run,- "kicadParser": whole, "dropped": [], "kept": names,- "_hint": "KiCad already loads this library; nothing to prune."});- }- let per = refused_symbols(cli, &blocks);- let accepted: Vec<&str> = per["accepted"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();- let kept: Vec<&(String, String)> = blocks.iter().filter(|(n, _)| accepted.contains(&n.as_str())).collect();- let kept_names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect();- let refused_count = per["refused"].as_object().map(|o| o.len()).unwrap_or(0);- let mut out = json!({"success": true, "mode": "prune", "dryRun": dry_run, "registeredPath": reg,- "kicadParserBefore": whole, "dropped": per["refused"], "kept": kept_names, "sourceNotes": notes});- if dry_run {- out["healed"] = json!(false);- out["_hint"] = json!(format!("Would rebuild {reg} with {} symbol(s) KiCad accepts and drop {refused_count} it refuses (names in dropped). Nothing written.", kept.len()));- return out;- }- let kept_blocks: Vec<&str> = kept.iter().map(|(_, b)| b.as_str()).collect();- let new_text = wrap_symbol_blocks(&kept_blocks).replace("\"adom-parse-check\"", "\"adom-desktop\"");- let after = verdict_text(cli, &new_text);- if !after["ok"].as_bool().unwrap_or(false) {- out["success"] = json!(false);- out["healed"] = json!(false);- out["kicadParserAfter"] = after;- out["error"] = json!("the pruned library is STILL refused by KiCad; nothing written (report this with the file)");- return out;- }- let backup = format!("{reg}.pruned.bak");- if let Err(e) = std::fs::write(&backup, &text) {- return json!({"success": false, "error": format!("cannot write backup {backup}: {e}")});- }- if let Err(e) = write_atomic(Path::new(®), new_text.as_bytes()) {- return json!({"success": false, "error": e});- }- out["healed"] = json!(true);- out["kicadParserAfter"] = after;- out["backup"] = json!(backup);- out["_hint"] = json!(format!("Rebuilt with {} symbol(s) KiCad accepts; dropped {refused_count} (see dropped, with KiCad's reason each). Original saved beside it. KiCad sees the change after its next library load.", kept.len()));- return out;- }-- if !rep["split"].as_bool().unwrap_or(false) {- let mut out = json!({"success": true, "healed": false, "reason": "no split"});- merge_into(&mut out, &rep);- return out;- }- let registered = PathBuf::from(rep["registeredPath"].as_str().unwrap_or(""));- let orphan = PathBuf::from(rep["bridgeDefaultPath"].as_str().unwrap_or(""));- if mode == "repoint" {- let Some((table_path, _)) = libraries::adom_paths(ctx) else {- return json!({"success": false, "error": "no Adom row to repoint"});- };- let mut table = match LibTable::parse_file(&table_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),- };- let Some(row) = table.entries.iter_mut().find(|e| e.name == ADOM_LIB_NAME) else {- return json!({"success": false, "error": "no Adom row to repoint"});- };- if !dry_run {- row.uri = norm_path(&orphan);- if let Err(e) = table.write_file(&table_path) {- return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});- }- }- return json!({"success": true, "healed": !dry_run, "mode": "repoint", "dryRun": dry_run,- "rowNowPointsAt": norm_path(&orphan),- "_hint": "KiCad reads the repointed file after its next library load (restart)."});- }- if mode != "merge" {- return json!({"success": false, "error": format!("unknown mode {mode:?}"), "allowed": ["merge", "repoint", "prune"]});- }- let orphan_text = match std::fs::read_to_string(&orphan) {- Ok(t) => t,- Err(e) => return json!({"success": false, "error": format!("cannot read orphan: {e}")}),- };- // TOLERANT SOURCE READ: symbols after the library's closer (the old appender- // bug) are still offered, each balance-checked on its own.- let (src_symbols, unparseable) = extract_symbols_tolerant(&orphan_text);- let have: Vec<String> = list_symbols(®istered).into_iter().map(|n| n.to_lowercase()).collect();- let to_add: Vec<&(String, String)> = src_symbols.iter().filter(|(n, _)| !have.contains(&n.to_lowercase())).collect();- let skipped: Vec<&str> = src_symbols.iter().filter(|(n, _)| have.contains(&n.to_lowercase())).map(|(n, _)| n.as_str()).collect();- if dry_run {- return json!({"success": true, "healed": false, "mode": "merge", "dryRun": true,- "wouldAdd": to_add.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), "alreadyPresent": skipped,- "unparseableInSource": unparseable,- "registeredPath": norm_path(®istered), "orphanPath": norm_path(&orphan)});- }- let mut added: Vec<String> = Vec::new();- let mut failed: Vec<Value> = Vec::new();- for (name, text) in to_add {- // add_symbol takes a WHOLE .kicad_sym; row-first paths land it in the registered file.- let wrapped = format!("(kicad_symbol_lib (version 20231120) (generator \"adom-heal\")\n{text}\n)\n");- let r = add_symbol(ctx, &wrapped);- if r["success"].as_bool().unwrap_or(false) {- added.push(name.clone());- } else {- failed.push(json!({"symbol": name, "error": r["error"]}));- }- }- let after = libraries::library_split_report(ctx);- json!({"success": failed.is_empty(), "healed": true, "mode": "merge", "dryRun": false,- "added": added, "alreadyPresent": skipped, "failed": failed,- "unparseableInSource": unparseable,- "registeredSymbolsAfter": after["registeredSymbols"],- "_hint": "Merged into the file KiCad reads. A running KiCad sees them after its next library load (kicad_rescan_libraries force:true, or restart). The orphan file was not modified or deleted."})-}--// ----------------------------------------------------------------------------// Part lookups shared by make_part_project and export_part-// -----------------------------------------------------------------------------/// The Adom.kicad_sym KiCad reads (row first, wiki #71), falling back to the-/// bridge default `<user>/symbols/Adom.kicad_sym` when no row resolves.-fn adom_sym_lib(ctx: &LibCtx, user_dir: &Path) -> PathBuf {- libraries::adom_paths(ctx).map(|(_, p)| p).unwrap_or_else(|| user_dir.join("symbols").join(ADOM_LIB_FILE))-}--/// The Adom.pretty KiCad reads (row first), with `${...}` uris expanded.-fn adom_pretty(ctx: &LibCtx, user_dir: &Path) -> PathBuf {- let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);- let Some(cfg) = &ctx.config_dir else { return default };- if let Ok(t) = LibTable::parse_file(&cfg.join("fp-lib-table")) {- if let Some(row) = t.get(ADOM_FP_LIB_NAME) {- if let Some(p) = expand_kicad_uri(&row.uri, ctx) {- return PathBuf::from(p);- }- }- }- default-}--/// Documents/ for this user: the grandparent of Documents/KiCad/<ver>, which is-/// what every platform's user_dir is.-fn documents_dir(user_dir: &Path) -> PathBuf {- user_dir.parent().and_then(Path::parent).map(Path::to_path_buf).unwrap_or_else(|| user_dir.to_path_buf())-}--fn find_symbol_block(lib_text: &str, name: &str) -> Result<Option<String>, String> {- Ok(extract_top_level_symbols(lib_text)?.into_iter().find(|(n, _)| n == name).map(|(_, b)| b))-}--/// `[-\d.]+` at the start of `s`, parsed. Returns (value, consumed).-fn num_token(s: &str) -> Option<(f64, usize)> {- let n = s.bytes().take_while(|b| b.is_ascii_digit() || *b == b'-' || *b == b'.').count();- if n == 0 {- return None;- }- s[..n].parse::<f64>().ok().map(|v| (v, n))-}--fn skip_ws(s: &str) -> &str {- s.trim_start()-}--/// Python `_pins`: `(pin TYPE SHAPE (at X Y ANGLE))` connection points.-fn pins(sym_block: &str) -> Vec<(f64, f64, f64)> {- let mut out = Vec::new();- let mut pos = 0;- while let Some(i) = sym_block[pos..].find("(pin") {- let after = pos + i + "(pin".len();- pos = after;- let s = &sym_block[after..];- if !s.starts_with(|c: char| c.is_ascii_whitespace()) {- continue;- }- let s = skip_ws(s);- let t1 = s.split_whitespace().next().unwrap_or("");- if t1.is_empty() {- continue;- }- let s = skip_ws(&s[t1.len()..]);- let t2 = s.split_whitespace().next().unwrap_or("");- if t2.is_empty() {- continue;- }- let s = skip_ws(&s[t2.len()..]);- let Some(s) = s.strip_prefix("(at") else { continue };- if !s.starts_with(|c: char| c.is_ascii_whitespace()) {- continue;- }- let s = skip_ws(s);- let Some((x, n)) = num_token(s) else { continue };- let s = skip_ws(&s[n..]);- let Some((y, n)) = num_token(s) else { continue };- let s = skip_ws(&s[n..]);- let Some((a, n)) = num_token(s) else { continue };- if s[n..].starts_with(')') {- out.push((x, y, a));- }- }- out-}--/// Python `_pads`: `(pad "N" ... (at X Y` with no parens in between.-fn pads(fp_text: &str) -> Vec<(f64, f64)> {- let mut out = Vec::new();- let mut pos = 0;- while let Some(i) = fp_text[pos..].find("(pad") {- let after = pos + i + "(pad".len();- pos = after;- let s = &fp_text[after..];- if !s.starts_with(|c: char| c.is_ascii_whitespace()) {- continue;- }- let s = skip_ws(s);- let Some(q) = s.strip_prefix('"') else { continue };- let Some(end) = q.find('"') else { continue };- let s = &q[end + 1..];- let stop = s.find(['(', ')']).unwrap_or(s.len());- let s = &s[stop..];- let Some(s) = s.strip_prefix("(at") else { continue };- if !s.starts_with(|c: char| c.is_ascii_whitespace()) {- continue;- }- let s = skip_ws(s);- let Some((x, n)) = num_token(s) else { continue };- let s = skip_ws(&s[n..]);- let Some((y, _)) = num_token(s) else { continue };- out.push((x, y));- }- out-}--/// Every `(start|end|at|center X Y` pair in a footprint.-fn coords(fp_text: &str) -> Vec<(f64, f64)> {- let mut out = Vec::new();- let mut pos = 0;- while let Some(i) = fp_text[pos..].find('(') {- let after = pos + i + 1;- pos = after;- let s = &fp_text[after..];- let Some(rest) = ["start", "end", "at", "center"].iter().find_map(|w| s.strip_prefix(w)) else { continue };- if !rest.starts_with(|c: char| c.is_ascii_whitespace()) {- continue;- }- let s = skip_ws(rest);- let Some((x, n)) = num_token(s) else { continue };- let s = skip_ws(&s[n..]);- let Some((y, _)) = num_token(s) else { continue };- out.push((x, y));- }- out-}--fn reference_property(block: &str) -> Option<String> {- let mut pos = 0;- while let Some(i) = block[pos..].find("(property") {- let after = pos + i + "(property".len();- pos = after;- let s = skip_ws(&block[after..]);- let Some(s) = s.strip_prefix("\"Reference\"") else { continue };- let s = skip_ws(s);- let Some(q) = s.strip_prefix('"') else { continue };- let Some(end) = q.find('"') else { continue };- return Some(q[..end].to_string());- }- None-}--fn replace_first(text: &str, from: &str, to: &str) -> String {- match text.find(from) {- Some(i) => format!("{}{}{}", &text[..i], to, &text[i + from.len()..]),- None => text.to_string(),- }-}--fn uuid(n: u64) -> String {- format!("00000000-0000-4000-8000-{n:012}")-}--// ----------------------------------------------------------------------------// kicad_make_part_project (handlers/make_part_project.py)-// -----------------------------------------------------------------------------/// Generate `<proj>.kicad_pro/.kicad_sch/.kicad_pcb` around one installed Adom-/// part: the symbol alone with two stub wires, the footprint alone inside tight-/// edge cuts with two copper stubs, the footprint's `(model ...)` kept.-/// Registering the project with the focus guardian is window-phase and skipped.-pub fn make_part_project(ctx: &LibCtx, args: &Value) -> Value {- let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();- if part.is_empty() {- return json!({"success": false, "error": "partName required",- "_hint": "The symbol's name in the Adom library: kicad_install_symbol first."});- }- let fp_name = {- let f = s_arg(args, "footprintName");- if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }- };- let proj = {- let p = s_arg(args, "projectName").trim().to_string();- if !p.is_empty() {- p- } else {- // re.sub(r"[^a-z0-9-]+", "-", part.lower()).strip("-") + "-demo"- let mut s = String::new();- let mut in_run = false;- for c in part.to_lowercase().chars() {- if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {- s.push(c);- in_run = false;- } else if !in_run {- s.push('-');- in_run = true;- }- }- format!("{}-demo", s.trim_matches('-'))- }- };-- let Some(user_dir) = ctx.user_dir.clone() else {- return json!({"success": false, "error": "KiCad user directory not found",- "_hint": "kicad_status reports install state; run once with KiCad installed."});- };- let sym_lib = adom_sym_lib(ctx, &user_dir);- if !sym_lib.exists() {- return json!({"success": false, "error": format!("Adom library not found at {}", norm_path(&sym_lib)),- "_hint": "kicad_install_symbol installs the part into the Adom library first."});- }- let lib_text = std::fs::read_to_string(&sym_lib).unwrap_or_default();- let block = match find_symbol_block(&lib_text, &part) {- Ok(Some(b)) => b,- Ok(None) => {- return json!({"success": false, "error": format!("Symbol '{part}' not in the Adom library"),- "_hint": "kicad_install_symbol with the part's .kicad_sym first (wiki part pages ship it)."})- }- Err(e) => return json!({"success": false, "error": format!("Adom library at {} does not parse: {e}", norm_path(&sym_lib))}),- };- let fp_path = adom_pretty(ctx, &user_dir).join(format!("{fp_name}.kicad_mod"));- if !fp_path.exists() {- return json!({"success": false, "error": format!("Footprint '{fp_name}' not in Adom.pretty"),- "_hint": "kicad_install_footprint (with model:{...} for the 3D step) first."});- }- let fp_text = std::fs::read_to_string(&fp_path).unwrap_or_default();-- let out_dir = match s_arg(args, "outputDir").trim() {- "" => documents_dir(&user_dir).join(&proj),- d => PathBuf::from(d),- };- // called as the standard's create_part_project: never overwrite without overwrite:true- if args.get("_refuseExisting") == Some(&json!(true)) && out_dir.join(format!("{proj}.kicad_pro")).exists() {- return json!({"success": false, "errorCode": "project_exists", "error": format!("A project named {proj} already exists in {}", norm_path(&out_dir)),- "_hint": "Pass overwrite:true to regenerate it in place, or another projectName."});- }- if let Err(e) = std::fs::create_dir_all(&out_dir) {- return json!({"success": false, "error": format!("Failed to write project files: cannot create {}: {e}", norm_path(&out_dir))});- }-- // The part names its own reference prefix.- let refdes = format!("{}1", reference_property(&block).map(|r| r.trim().to_string()).filter(|r| !r.is_empty()).unwrap_or_else(|| "U".into()));-- // ---------------- schematic: JUST the part ----------------- let sheet = uuid(1);- let (ax, ay) = (127.0f64, 80.0f64);- let lib_entry = format!(" {}", replace_first(&block, &format!("(symbol \"{part}\""), &format!("(symbol \"Adom:{part}\"")));- let mut wires = String::new();- let mut wire_n = 300u64;- for (px, py, pa) in pins(&block).into_iter().take(2) {- let (cx, cy) = (ax + px, ay - py);- let dx = -pa.to_radians().cos();- let dy = pa.to_radians().sin();- let (ex, ey) = (round_to(cx + 6.0 * dx, 3), round_to(cy + 6.0 * dy, 3));- wires.push_str(&format!(- " (wire (pts (xy {} {}) (xy {} {})) (stroke (width 0) (type default)) (uuid \"{}\"))\n",- py_float(round_to(cx, 3)), py_float(round_to(cy, 3)), py_float(ex), py_float(ey), uuid(wire_n)- ));- wire_n += 1;- }- let schematic = format!(- "(kicad_sch (version 20231120) (generator \"eeschema\") (generator_version \"8.0\")\n (uuid \"{sheet}\") (paper \"A4\")\n (title_block (title \"{part} demo - generated by the Adom KiCad Bridge\") (company \"Adom\"))\n (lib_symbols\n{lib_entry}\n )\n{wires} (symbol (lib_id \"Adom:{part}\") (at {ax} {ay} 0) (unit 1)\n (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid \"{u201}\")\n (property \"Reference\" \"{refdes}\" (at {ax} {ay_ref} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{part}\" (at {ax} {ay_val} 0) (effects (font (size 1.27 1.27))))\n (property \"Footprint\" \"Adom:{fp_name}\" (at {ax} {ay} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n (instances (project \"{proj}\" (path \"/{sheet}\" (reference \"{refdes}\") (unit 1))))\n )\n (sheet_instances (path \"/\" (page \"1\")))\n)\n",- ax = py_float(ax), ay = py_float(ay), ay_ref = py_float(ay - 7.5), ay_val = py_float(ay + 7.0), u201 = uuid(201)- );-- // ---------------- board: JUST the part, centered ----------------- let part_at = (108.0f64, 68.0f64);- let cs = coords(&fp_text);- let half_w = cs.iter().map(|(x, _)| x.abs()).fold(1.0f64, f64::max);- let half_h = cs.iter().map(|(_, y)| y.abs()).fold(1.0f64, f64::max);- let margin = (2.0f64).max(half_h * 0.8);- let x1 = round_to(part_at.0 - half_w - margin, 2);- let y1 = round_to(part_at.1 - half_h.max(1.0) - margin, 2);- let x2 = round_to(part_at.0 + half_w + margin, 2);- let y2 = round_to(part_at.1 + half_h.max(1.0) + margin, 2);- let fp_placed = replace_first(&fp_text, &format!("(footprint \"{fp_name}\""), &format!("(footprint \"Adom:{fp_name}\""));- let fp_placed = replace_first(&fp_placed, "(layer \"F.Cu\")", &format!("(layer \"F.Cu\")\n (at {} {})", py_float(part_at.0), py_float(part_at.1)));- let fp_placed = replace_first(&fp_placed, "(fp_text reference \"REF**\"", &format!("(fp_text reference \"{refdes}\""));- let fp_placed = replace_first(&fp_placed, "(property \"Reference\" \"REF**\"", &format!("(property \"Reference\" \"{refdes}\""));-- let mut edge = String::new();- let pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)];- for i in 0..4 {- let ((ex1, ey1), (ex2, ey2)) = (pts[i], pts[i + 1]);- edge.push_str(&format!(- " (gr_line (start {} {}) (end {} {}) (stroke (width 0.15) (type solid)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n",- py_float(ex1), py_float(ey1), py_float(ex2), py_float(ey2), uuid(400 + i as u64)- ));- }-- // Two short copper stubs off the pads keep the board looking real.- let mut segments = String::new();- let pd = pads(&fp_text);- if pd.len() >= 2 && (pd[0].1 - pd[1].1).abs() < 0.01 {- let mut two = vec![pd[0], pd[1]];- two.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));- let (left, right) = (two[0], two[1]);- let py_abs = part_at.1 + left.1;- segments.push_str(&format!(- " (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n",- py_float(round_to(part_at.0 + left.0, 3)), py_float(round_to(py_abs, 3)),- py_float(round_to(x1 + 0.8, 3)), py_float(round_to(py_abs, 3)),- py_float(round_to(part_at.0 + right.0, 3)), py_float(round_to(py_abs, 3)),- py_float(round_to(x2 - 0.8, 3)), py_float(round_to(py_abs, 3)),- ));- }- let fps = format!(" {}", fp_placed.trim_end().replace('\n', "\n "));- let board = format!(- "(kicad_pcb (version 20240108) (generator \"pcbnew\") (generator_version \"8.0\")\n (general (thickness 1.6)) (paper \"A4\")\n (layers\n (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)\n (36 \"B.SilkS\" user \"B.Silkscreen\") (37 \"F.SilkS\" user \"F.Silkscreen\")\n (38 \"B.Mask\" user) (39 \"F.Mask\" user) (40 \"B.Paste\" user) (41 \"F.Paste\" user)\n (44 \"Edge.Cuts\" user) (45 \"Margin\" user) (49 \"F.Fab\" user) (50 \"B.Fab\" user)\n (46 \"B.CrtYd\" user \"B.Courtyard\") (47 \"F.CrtYd\" user \"F.Courtyard\")\n )\n (setup (pad_to_mask_clearance 0))\n{edge}{fps}\n{segments})\n"- );- let pro = format!("{{\"board\":{{}},\"meta\":{{\"filename\":\"{proj}.kicad_pro\",\"version\":1}},\"schematic\":{{}},\"sheets\":[]}}");-- let mut files = Map::new();- for (name, content) in [(format!("{proj}.kicad_pro"), &pro), (format!("{proj}.kicad_sch"), &schematic), (format!("{proj}.kicad_pcb"), &board)] {- let p = out_dir.join(&name);- if let Err(e) = std::fs::write(&p, content) {- return json!({"success": false, "error": format!("Failed to write project files: {e}")});- }- let key = name.rsplit('.').next().unwrap_or("").to_string();- files.insert(key, json!(norm_path(&p)));- }- // Stale single-instance locks from a previous open of the same project.- if let Ok(rd) = std::fs::read_dir(&out_dir) {- for e in rd.flatten() {- if has_ext(&e.path(), "lck") {- let _ = std::fs::remove_file(e.path());- }- }- }- for (name, content) in [("kicad_sch", &schematic), ("kicad_pcb", &board)] {- if content.matches('(').count() != content.matches(')').count() {- return json!({"success": false, "error": format!("internal: unbalanced s-expression in {proj}.{name}")});- }- }- json!({- "success": true,- "projectName": proj,- "projectFile": files["kicad_pro"],- "schematicFile": files["kicad_sch"],- "boardFile": files["kicad_pcb"],- "partName": part,- "footprintName": fp_name,- "reference": refdes,- "output": format!("Generated {proj}: the part alone as {refdes}, single-component by design (schematic + part-dominant board with edge cuts, copper stubs and the part's 3D model)."),- "_hint": "Project surfaces now have something real to show: kicad_show_project {filePath: projectFile}, kicad_show_schematic {filePath: schematicFile}, kicad_show_2d_board / kicad_show_3d_board {filePath: boardFile}. The footprint keeps its (model ...) reference, so the 3D board renders the actual part. Regenerating overwrites in place.",- "_next": ["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board"],- })-}--// ----------------------------------------------------------------------------// kicad_export_part (handlers/export_part.py)-// -----------------------------------------------------------------------------/// One-off single-part library exports: a `.kicad_sym` with ONLY that symbol, a-/// `.pretty` with ONLY that footprint, the bare 3D model file.-pub fn export_part(ctx: &LibCtx, args: &Value) -> Value {- let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();- if part.is_empty() {- return json!({"success": false, "error": "partName required"});- }- let fp_name = {- let f = s_arg(args, "footprintName");- if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }- };- let what: Vec<String> = match args.get("what") {- Some(Value::String(s)) => vec![s.to_lowercase()],- Some(Value::Array(a)) if !a.is_empty() => a.iter().filter_map(Value::as_str).map(|s| s.to_lowercase()).collect(),- _ => vec!["symbol".into(), "footprint".into(), "model".into()],- };- let Some(user_dir) = ctx.user_dir.clone() else {- return json!({"success": false, "error": "KiCad user directory not found"});- };- let out_dir = match s_arg(args, "outputDir").trim() {- "" => documents_dir(&user_dir).join("adom-part-exports").join(&part),- d => PathBuf::from(d),- };- if let Err(e) = std::fs::create_dir_all(&out_dir) {- return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&out_dir))});- }- let mut exports = Map::new();- let mut errors: Vec<String> = Vec::new();- let pretty_src = adom_pretty(ctx, &user_dir);-- if what.iter().any(|w| w == "symbol") {- let sym_lib = adom_sym_lib(ctx, &user_dir);- match std::fs::read_to_string(&sym_lib).map_err(|e| e.to_string()).and_then(|t| extract_top_level_symbols(&t)) {- Err(e) => errors.push(format!("symbol export: {e}")),- Ok(blocks) => match blocks.into_iter().find(|(n, _)| *n == part) {- None => errors.push(format!("symbol '{part}' not in {}", norm_path(&sym_lib))),- Some((_, blk)) => {- let one = format!("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n{}\n)\n", blk.trim_end());- let p = out_dir.join(format!("{part}-symbol-only.kicad_sym"));- match std::fs::write(&p, &one) {- Err(e) => errors.push(format!("symbol export: {e}")),- Ok(()) => {- let names: Vec<String> = extract_top_level_symbols(&one).map(|v| v.into_iter().map(|(n, _)| n).collect()).unwrap_or_default();- let single = names.len() == 1 && names[0] == part;- exports.insert("symbol".into(), json!({"path": norm_path(&p), "symbolsInLibrary": names, "single": single}));- }- }- }- },- }- }-- if what.iter().any(|w| w == "footprint") {- let src = pretty_src.join(format!("{fp_name}.kicad_mod"));- if !src.exists() {- errors.push(format!("footprint '{fp_name}' not in Adom.pretty"));- } else {- let pretty = out_dir.join(format!("{part}-footprint-only.pretty"));- let dst = pretty.join(format!("{fp_name}.kicad_mod"));- match std::fs::create_dir_all(&pretty).map_err(|e| e.to_string()).and_then(|_| std::fs::copy(&src, &dst).map(|_| ()).map_err(|e| e.to_string())) {- Err(e) => errors.push(format!("footprint export: {e}")),- Ok(()) => {- let mods: Vec<String> = kicad_mods(&pretty).iter().map(|p| file_name(p)).collect();- let single = mods.len() == 1 && mods[0] == format!("{fp_name}.kicad_mod");- exports.insert("footprint".into(), json!({"path": norm_path(&dst), "library": norm_path(&pretty), "footprintsInLibrary": mods, "single": single}));- }- }- }- }-- if what.iter().any(|w| w == "model") {- let models_dir = user_dir.join("3dmodels").join("Adom");- let mut src: Option<PathBuf> = None;- for ext in [".step", ".stp", ".wrl"] {- let c = models_dir.join(format!("{fp_name}{ext}"));- if c.exists() {- src = Some(c);- break;- }- }- if src.is_none() {- // Fall back to the model the footprint references.- let fp = pretty_src.join(format!("{fp_name}.kicad_mod"));- if let Ok(t) = std::fs::read_to_string(&fp) {- if let Some((_, reference)) = model_refs(&t).into_iter().next() {- let p = PathBuf::from(&reference);- if p.exists() {- src = Some(p);- }- }- }- }- match src {- None => errors.push(format!("no 3D model for '{fp_name}' in 3dmodels/Adom")),- Some(src) => {- let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();- let dst = out_dir.join(format!("{part}-model-only{ext}"));- match std::fs::copy(&src, &dst) {- Err(e) => errors.push(format!("model export: {e}")),- Ok(_) => {- let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0);- exports.insert("model".into(), json!({"path": norm_path(&dst), "bytes": bytes}));- }- }- }- }- }-- let ok = exports.len() == what.len() && errors.is_empty();- let mut kinds: Vec<&String> = exports.keys().collect();- kinds.sort();- let output = format!(- "one-off exports for {part}: {}{}",- kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>().join(", "),- if errors.is_empty() { String::new() } else { format!(" | {} error(s)", errors.len()) }- );- json!({- "success": ok,- "partName": part,- "outputDir": norm_path(&out_dir),- "exports": exports,- "errors": errors,- "output": output,- "_hint": "Each file is a standalone single-entry library: the .kicad_sym holds ONLY this symbol (open with kicad_show_symbol after installing, or any KiCad), the .pretty holds ONLY this footprint, the model file is the bare 3D body. The reverse of install: proves per-asset round-trip. Pull them with ab pull_file.",- })-}--// ---- uninstall_library -----------------------------------------------------------------------/// The exact inverse of `install_library`: remove a library's row from the user's-/// sym-lib-table and/or fp-lib-table and, only when asked, delete the files the install copied.-///-/// Safety rules (each refusal is a named errorCode with the next call in the hint):-/// - the shared `Adom` library is never removed here (every Adom part lives in it);-/// - a row whose uri is a KiCad stock path (`${KICAD*_..._DIR}`) is never removed: that is-/// KiCad's own library set, not something the bridge installed;-/// - `deleteFiles` only ever deletes inside `Documents/KiCad/<ver>/{symbols,footprints,3dmodels}`,-/// the folders the install verbs write to. A library registered in place from anywhere else-/// is unregistered and its files are left alone (`filesKept` says why).-pub fn uninstall_library(ctx: &LibCtx, kicad_running: bool, args: &Value) -> Value {- let name = s_arg(args, "libraryName");- if name.is_empty() {- return json!({"success": false, "errorCode": "library_name_required",- "error": "libraryName is required",- "Hint": "kicad_uninstall_library {\"libraryName\":\"MyParts\"}; the name is the row name in Preferences > Manage Symbol/Footprint Libraries (kicad_install_library returned it as data.name)."});- }- let which = s_arg(args, "libraryType").to_lowercase();- if !matches!(which.as_str(), "" | "both" | "symbol" | "footprint") {- return json!({"success": false, "errorCode": "bad_library_type",- "error": format!("libraryType must be symbol, footprint or both (default), got '{which}'"),- "Hint": "omit libraryType to remove the name from both tables"});- }- if name.eq_ignore_ascii_case("Adom") {- return json!({"success": false, "errorCode": "adom_library_protected",- "error": "the shared 'Adom' library is not removed by this verb: every part installed with kicad_install_symbol / kicad_install_footprint lives in it",- "Hint": "remove one part with the Symbol/Footprint Editor, or repair the library with kicad_adom_library_heal"});- }- let delete_files = args.get("deleteFiles").and_then(Value::as_bool).unwrap_or(false);- let user_dir = ctx.user_dir.clone();- let mut removed: Vec<Value> = Vec::new();- let mut files_deleted: Vec<String> = Vec::new();- let mut files_kept: Vec<Value> = Vec::new();- let mut stock: Vec<String> = Vec::new();-- let tables: Vec<(&str, Option<PathBuf>)> = vec![("symbol", ctx.sym_lib_table()), ("footprint", ctx.fp_lib_table())];- for (kind, table_path) in tables {- if !(which.is_empty() || which == "both" || which == kind) {- continue;- }- let Some(table_path) = table_path else { continue };- if !table_path.is_file() {- continue;- }- let mut table = match LibTable::parse_file(&table_path) {- Ok(t) => t,- Err(e) => return json!({"success": false, "errorCode": "lib_table_unreadable",- "error": format!("cannot parse {}: {e}", table_path.display()),- "Hint": "kicad_adom_library_heal repairs a damaged lib table"}),- };- let Some(entry) = table.get(&name).cloned() else { continue };- // A fresh KiCad 10 install holds ALL its stock libraries in one row of type "Table" (named- // "KiCad", uri = the template table, an absolute path with no ${KICAD..} in it). Removing that- // row would take every stock library away, so a nested-table row is stock by definition.- if entry.lib_type.eq_ignore_ascii_case("Table") {- stock.push(format!("{kind}: nested table {}", entry.uri));- continue;- }- if entry.uri.contains("${KICAD") && entry.uri.contains("_DIR}") && !entry.uri.contains("3RD_PARTY") {- stock.push(format!("{kind}: {}", entry.uri));- continue;- }- table.remove_library(&name);- if let Err(e) = table.write_file(&table_path) {- return json!({"success": false, "errorCode": "lib_table_write_failed",- "error": format!("cannot write {}: {e}", table_path.display()),- "Hint": "close KiCad's Manage Libraries dialog if it is open, then retry"});- }- removed.push(json!({"table": if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" }, "uri": entry.uri}));-- let resolved = crate::libraries::expand_kicad_uri(&entry.uri, ctx).unwrap_or_else(|| entry.uri.clone());- let target = PathBuf::from(&resolved);- if !delete_files {- continue;- }- let managed = user_dir.as_ref().map(|u| {- let sub = u.join(if kind == "symbol" { "symbols" } else { "footprints" });- // a path component boundary, not a bare string prefix: "footprints-old/x" is NOT under "footprints"- let (t, base) = (crate::libraries::norm_path(&target), format!("{}/", crate::libraries::norm_path(&sub).trim_end_matches('/')));- t.starts_with(&base) && !t.split('/').any(|c| c == "..")- }).unwrap_or(false);- if !managed {- files_kept.push(json!({"path": resolved, "why": "outside Documents/KiCad/<ver>/symbols|footprints: registered in place, not a copy the bridge made"}));- continue;- }- let gone = if target.is_dir() { std::fs::remove_dir_all(&target).is_ok() } else if target.is_file() { std::fs::remove_file(&target).is_ok() } else { false };- if gone {- files_deleted.push(resolved.clone());- }- if kind == "footprint" {- if let Some(u) = &user_dir {- let models = u.join("3dmodels").join(format!("{name}.3dshapes"));- if models.is_dir() && std::fs::remove_dir_all(&models).is_ok() {- files_deleted.push(models.to_string_lossy().replace('\\', "/"));- }- }- }- }-- if removed.is_empty() {- if !stock.is_empty() {- return json!({"success": false, "errorCode": "stock_library_protected", "libraryName": name, "stock": stock,- "error": format!("'{name}' is one of KiCad's own stock libraries; this verb only removes libraries that were added to the user tables"),- "Hint": "disable a stock library in Preferences > Manage Libraries instead (the Active checkbox)"});- }- return json!({"success": false, "errorCode": "library_not_registered", "libraryName": name,- "error": format!("no library named '{name}' in the {} lib table(s) of KiCad {}", if which.is_empty() || which == "both" { "symbol or footprint" } else { which.as_str() }, ctx.version),- "Hint": "names are case-sensitive and per KiCad version: kicad_list_versions, then pass kicadVersion; kicad_show_library lists what is registered"});- }- let mut out = json!({"success": true, "libraryName": name, "kicadVersion": ctx.version,- "removed": removed, "filesDeleted": files_deleted, "filesKept": files_kept,- "deleteFiles": delete_files, "kicadRunning": kicad_running, "tableReloadNeeded": kicad_running});- if !stock.is_empty() {- out["stockRowsLeft"] = json!(stock);- }- out["Hint"] = json!(if kicad_running {- "Removed from the table on disk. KiCad is RUNNING and holds its lib tables in memory: the library stays listed (and usable) in this session until KiCad restarts or the user presses OK in Preferences > Manage Libraries. If KiCad then saves its tables it can write the row back: prefer uninstalling with KiCad closed."- } else if delete_files {- "Removed from the lib table(s) and the installed copy deleted. kicad_install_library puts it back."- } else {- "Removed from the lib table(s); the library files were left in place. Pass deleteFiles:true to also delete the copy under Documents/KiCad/<ver>/ (never files outside it)."- });- out-}--// ----------------------------------------------------------------------------// Tests-// -----------------------------------------------------------------------------#[cfg(test)]-mod tests {- use super::*;-- const SYM_R: &str = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n\t(symbol \"R_Adom\"\n\t\t(property \"Reference\" \"R\" (at 0 0 0))\n\t\t(property \"Value\" \"R\" (at 0 0 0))\n\t\t(symbol \"R_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n\t\t(symbol \"R_Adom_1_1\"\n\t\t\t(pin passive line\n\t\t\t\t(at 0 3.81 270) (length 1.27) (name \"~\") (number \"1\"))\n\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name \"~\") (number \"2\"))\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n";-- const SYM_C: &str = "(kicad_symbol_lib (version 20231120) (generator \"x\")\n (symbol \"C_Adom\"\n (property \"Reference\" \"C\" (at 0 0 0))\n (property \"Value\" \"PA13(JTMS/SWDIO)\" (at 0 0 0))\n (symbol \"C_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n )\n)\n";-- const MOD_R: &str = "(footprint \"R_0805_Adom\"\n\t(version 20240108)\n\t(generator \"pcbnew\")\n\t(layer \"F.Cu\")\n\t(property \"Reference\" \"REF**\"\n\t\t(at 0 -1.65 0)\n\t\t(layer \"F.SilkS\")\n\t)\n\t(attr smd)\n\t(fp_rect\n\t\t(start -1.68 -0.95)\n\t\t(end 1.68 0.95)\n\t\t(layer \"F.CrtYd\")\n\t)\n\t(pad \"1\" smd roundrect\n\t\t(at -0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(pad \"2\" smd roundrect\n\t\t(at 0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(model \"3dmodels/R_0805.step\"\n\t\t(offset (xyz 0 0 0))\n\t\t(scale (xyz 1 1 1))\n\t\t(rotate (xyz 0 0 0))\n\t)\n)\n";-- struct Box_ {- root: PathBuf,- ctx: LibCtx,- }-- impl Drop for Box_ {- fn drop(&mut self) {- let _ = std::fs::remove_dir_all(&self.root);- }- }-- /// A fake box: <root>/kicad (install with a template), <root>/cfg, <root>/Documents/KiCad/10.0.- fn fake_box(name: &str) -> Box_ {- let root = unique_temp_dir(&format!("adom-install-{name}-"));- let _ = std::fs::remove_dir_all(&root);- let base = root.join("kicad");- let tmpl = base.join("share/kicad/template");- std::fs::create_dir_all(&tmpl).unwrap();- std::fs::write(tmpl.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Device\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Device.kicad_sym\")(options \"\")(descr \"Generic devices\"))\n (lib (name \"Connector\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Connector.kicad_sym\")(options \"\")(descr \"\"))\n)\n").unwrap();- std::fs::write(tmpl.join("fp-lib-table"), "(fp_lib_table\n (version 7)\n (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n").unwrap();- let cfg = root.join("cfg");- let user = root.join("Documents/KiCad/10.0");- let ctx = LibCtx {- version: "10.0".into(),- base_dir: Some(norm_path(&base)),- config_dir: Some(cfg),- user_dir: Some(user),- kicad_cli: None,- env_overrides: BTreeMap::new(),- };- Box_ { root, ctx }- }-- fn b64(s: &str) -> String {- const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";- let bytes = s.as_bytes();- let mut out = String::new();- for chunk in bytes.chunks(3) {- let n = chunk.len();- let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];- let v = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;- out.push(T[(v >> 18) as usize & 63] as char);- out.push(T[(v >> 12) as usize & 63] as char);- out.push(if n > 1 { T[(v >> 6) as usize & 63] as char } else { '=' });- out.push(if n > 2 { T[v as usize & 63] as char } else { '=' });- }- out- }-- #[test]- fn base64_round_trip_and_errors() {- let text = "(kicad_symbol_lib (symbol \"A\"))\n";- assert_eq!(b64_decode(&b64(text)).unwrap(), text.as_bytes());- assert_eq!(b64_decode("aGk=").unwrap(), b"hi");- assert_eq!(b64_decode("aGk").unwrap(), b"hi");- assert_eq!(b64_decode("aG\nk=").unwrap(), b"hi"); assert!(b64_decode("a*k=").is_err()); } - #[test]- fn missing_global_tables_are_created_and_existing_ones_kept() {- let dir = std::env::temp_dir().join(format!("kicad-core-tables-{}", std::process::id()));- let _ = std::fs::remove_dir_all(&dir);- std::fs::create_dir_all(&dir).unwrap();- std::fs::write(dir.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Mine\"))\n)\n").unwrap();- let lines = ensure_global_tables(&dir, None, "10.0");- assert_eq!(lines.len(), 2, "fp and design-block written, sym kept: {lines:?}");- assert!(std::fs::read_to_string(dir.join("sym-lib-table")).unwrap().contains("Mine"));- assert!(std::fs::read_to_string(dir.join("design-block-lib-table")).unwrap().starts_with("(design_block_lib_table"));- assert!(ensure_global_tables(&dir, None, "10.0").is_empty(), "idempotent");- let nine = dir.join("nine");- let lines = ensure_global_tables(&nine, None, "9.0");- assert_eq!(lines.len(), 2);- assert!(!nine.join("design-block-lib-table").exists(), "KiCad 9 has no design-block table");- let _ = std::fs::remove_dir_all(&dir);- }- #[test] fn heal_lib_tables_seeds_and_merges() {- let bx = fake_box("heal");- let cfg = bx.ctx.config_dir.clone().unwrap();- let base = bx.ctx.base_dir.clone().unwrap();- // Missing tables: seeded from the template.- let r = heal_lib_tables(&cfg, Some(&base));- assert_eq!(r["sym-lib-table"]["seeded"], json!(true));- assert_eq!(r["fp-lib-table"]["seeded"], json!(true));- let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();- assert!(t.has_library("Device") && t.has_library("Connector"));- // An existing table missing stock rows: merged, user rows untouched.- std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Mine\")(type \"KiCad\")(uri \"C:/x/Mine.kicad_sym\")(options \"\")(descr \"mine\"))\n (lib (name \"Device\")(type \"KiCad\")(uri \"D:/custom/Device.kicad_sym\")(options \"\")(descr \"override\"))\n)\n").unwrap();- let r = heal_lib_tables(&cfg, Some(&base));- assert_eq!(r["sym-lib-table"]["merged"], json!(1));- assert_eq!(r["fp-lib-table"]["merged"], json!(0));- let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();- assert_eq!(t.entries.len(), 3);- assert_eq!(t.entries[0].name, "Mine");- assert_eq!(t.get("Device").unwrap().uri, "D:/custom/Device.kicad_sym");- assert_eq!(t.entries[2].name, "Connector");- // No template at all: still seeds an empty table, never errors.- let cfg2 = bx.root.join("cfg2");- let r = heal_lib_tables(&cfg2, None);- assert_eq!(r["sym-lib-table"]["seeded"], json!(true));- assert!(std::fs::read_to_string(cfg2.join("fp-lib-table")).unwrap().starts_with("(fp_lib_table"));- }-- #[test]- fn install_symbol_merges_into_adom_with_backup() {- let bx = fake_box("sym");- // Never-launched KiCad: nothing exists yet.- let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["symbolNames"], json!(["R_Adom"]));- assert_eq!(r["libraryName"], json!("Adom"));- assert_eq!(r["editorOpened"], json!(false));- assert_eq!(r["kicadParser"]["available"], json!(false));- let lib = PathBuf::from(r["installedPath"].as_str().unwrap());- assert!(lib.ends_with("Documents/KiCad/10.0/symbols/Adom.kicad_sym"), "{}", lib.display());- assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string()]);- // Bootstrap seeded the stock rows and registered Adom.- let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();- assert!(t.has_library("Device") && t.has_library("Adom"));- assert!(bx.ctx.user_dir.clone().unwrap().join("footprints").is_dir());- // Second symbol appends after the last symbol, before (embedded_fonts).- let c_path = bx.root.join("C.kicad_sym");- std::fs::write(&c_path, SYM_C).unwrap();- let r = install_symbol(&bx.ctx, true, &json!({"filePath": norm_path(&c_path), "libraryName": "AdomTest", "openEditor": false}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["ignoredArgs"], json!({"libraryName": "AdomTest"}));- assert!(r["_hint"].as_str().unwrap().contains("not honoured"));- assert!(!r["output"].as_str().unwrap().contains("Symbol Editor"));- let text = std::fs::read_to_string(&lib).unwrap();- assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string(), "C_Adom".to_string()]);- // The bridge's empty library template carries no (embedded_fonts) node (same as- // the Python's _EMPTY_LIB), so the merged file ends with the closing paren and- // the new symbol sits after the last existing one.- assert!(text.trim_end().ends_with(')'), "{text}");- assert!(text.find("(symbol \"R_Adom\"").unwrap() < text.find("(symbol \"C_Adom\"").unwrap());- assert!(lib.with_extension("kicad_sym.bak").is_file());- // Re-install replaces in place, count unchanged.- let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "quietInstall": true}));- assert_eq!(r["success"], json!(true));- assert_eq!(list_symbols(&lib).len(), 2);- // Errors.- assert_eq!(install_symbol(&bx.ctx, false, &json!({}))["error"], json!("KiCad not installed"));- assert_eq!(install_symbol(&bx.ctx, true, &json!({}))["error"], json!("No fileName specified"));- assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "x"}))["error"], json!("No fileContent specified (or pass filePath)"));- assert!(install_symbol(&bx.ctx, true, &json!({"filePath": "/nope/x.kicad_sym"}))["error"].as_str().unwrap().starts_with("filePath not found"));- let bad = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_pcb (x))")}));- assert!(bad["error"].as_str().unwrap().contains("not a valid symbol library"));- let empty = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_symbol_lib (version 1))")}));- assert_eq!(empty["error"], json!("No symbols found in the provided file content"));- }-- #[test]- fn install_symbol_honours_the_row_first_rule() {- let bx = fake_box("rowfirst");- let cfg = bx.ctx.config_dir.clone().unwrap();- let elsewhere = bx.root.join("OneDrive/Adom.kicad_sym");- std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();- std::fs::write(&elsewhere, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n)\n").unwrap();- let mut t = LibTable::empty("sym_lib_table");- t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));- std::fs::create_dir_all(&cfg).unwrap();- t.write_file(&cfg.join("sym-lib-table")).unwrap();- let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["installedPath"], json!(norm_path(&elsewhere)));- assert_eq!(list_symbols(&elsewhere), vec!["R_Adom".to_string()]);- assert!(!bx.ctx.user_dir.clone().unwrap().join("symbols/Adom.kicad_sym").exists());- }-- #[test]- fn add_symbol_refuses_a_corrupt_library() {- let bx = fake_box("corrupt");- let user = bx.ctx.user_dir.clone().unwrap();- std::fs::create_dir_all(user.join("symbols")).unwrap();- std::fs::create_dir_all(bx.ctx.config_dir.clone().unwrap()).unwrap();- std::fs::write(user.join("symbols/Adom.kicad_sym"), "(kicad_symbol_lib (version 1)\n)\n(symbol \"Stray\" (x))\n").unwrap();- let r = add_symbol(&bx.ctx, SYM_R);- assert_eq!(r["success"], json!(false));- assert!(r["error"].as_str().unwrap().starts_with("REFUSING to merge"), "{r}");- }-- #[test]- fn install_footprint_into_adom_pretty_with_model() {- let bx = fake_box("fp");- let step = bx.root.join("R_0805.step");- std::fs::write(&step, b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n").unwrap();- let r = install_footprint(&bx.ctx, true, &json!({- "fileName": "whatever.kicad_mod", "fileContent": b64(MOD_R),- "model": {"filePath": norm_path(&step)}- }));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["footprintName"], json!("R_0805_Adom"));- assert_eq!(r["libraryName"], json!("Adom"));- assert_eq!(r["existedBefore"], json!(false));- assert_eq!(r["unchanged"], json!(false));- assert_eq!(r["editorOpened"], json!(false));- let installed = PathBuf::from(r["installedPath"].as_str().unwrap());- assert!(installed.ends_with("footprints/Adom.pretty/R_0805_Adom.kicad_mod"));- let model = r["model3d"].as_str().unwrap().to_string();- assert!(model.ends_with("3dmodels/Adom/R_0805.step"));- let text = std::fs::read_to_string(&installed).unwrap();- assert!(text.contains(&format!("(model \"{model}\"")), "{text}");- assert!(!text.contains("3dmodels/R_0805.step"));- let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();- assert!(t.has_library("Adom") && t.has_library("Resistor_SMD"));- assert!(r["output"].as_str().unwrap().contains("registered in fp-lib-table"));- // Same content again: unchanged, not rewritten.- let r2 = install_footprint(&bx.ctx, true, &json!({"filePath": norm_path(&installed), "openEditor": false}));- assert_eq!(r2["existedBefore"], json!(true));- assert_eq!(r2["unchanged"], json!(true));- assert_eq!(r2["model3d"], Value::Null);- // A footprint with no model gets one injected before the closing paren.- let bare = "(footprint \"Bare\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1))\n)\n";- let r3 = install_footprint(&bx.ctx, true, &json!({"fileName": "Bare.kicad_mod", "fileContent": b64(bare),- "model": {"fileName": "Bare.wrl", "fileContent": b64("#VRML V2.0 utf8\n")}}));- assert_eq!(r3["success"], json!(true), "{r3}");- let text = std::fs::read_to_string(r3["installedPath"].as_str().unwrap()).unwrap();- assert!(text.ends_with(" (rotate (xyz 0 0 0))\n )\n)\n"), "{text}");- assert!(text.contains("3dmodels/Adom/Bare.wrl"));- // Missing model path is an error before anything is written.- let r4 = install_footprint(&bx.ctx, true, &json!({"fileName": "X.kicad_mod", "fileContent": b64(bare), "model": {"filePath": "/nope.step"}}));- assert_eq!(r4["success"], json!(false));- assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty/X.kicad_mod").exists());- }-- #[test]- fn install_footprint_row_first_directory() {- let bx = fake_box("fprow");- let cfg = bx.ctx.config_dir.clone().unwrap();- let elsewhere = bx.root.join("OneDrive/Adom.pretty");- let mut t = LibTable::empty("fp_lib_table");- t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));- std::fs::create_dir_all(&cfg).unwrap();- t.write_file(&cfg.join("fp-lib-table")).unwrap();- let r = install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R)}));- assert_eq!(r["success"], json!(true), "{r}");- assert!(elsewhere.join("R_0805_Adom.kicad_mod").is_file());- assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty").exists());- }-- #[test]- fn install_library_symbol_and_single_part_hint() {- let bx = fake_box("lib");- let src = bx.root.join("MyParts.kicad_sym");- std::fs::write(&src, SYM_R).unwrap();- let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["data"]["name"], json!("MyParts"));- assert_eq!(r["data"]["table"], json!("sym-lib-table"));- assert!(r["data"]["path"].as_str().unwrap().ends_with("symbols/MyParts.kicad_sym"));- assert_eq!(r["singlePartFile"], json!(true));- assert_eq!(r["symbolNames"], json!(["R_Adom"]));- assert!(r["_hint"].as_str().unwrap().contains("kicad_install_symbol"));- let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();- assert_eq!(t.get("MyParts").unwrap().descr, "Installed by Adom Bridge");- // Idempotent.- let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));- assert_eq!(r["success"], json!(true));- assert_eq!(r["alreadyRegistered"], json!(true));- // Errors.- assert_eq!(install_library(&bx.ctx, true, &json!({}))["error"], json!("No libraryPath specified"));- assert_eq!(install_library(&bx.ctx, false, &json!({"libraryPath": "x"}))["error"], json!("KiCad not installed"));- assert!(install_library(&bx.ctx, true, &json!({"libraryPath": "/nope.kicad_sym"}))["error"].as_str().unwrap().starts_with("Library source not found"));- assert!(install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src), "libraryType": "weird"}))["error"].as_str().unwrap().starts_with("Unknown library type"));- }-- #[test]- fn install_library_footprint_pretty_with_bundle_models() {- let bx = fake_box("libfp");- let bundle = bx.root.join("bundle");- let pretty = bundle.join("Cloud.pretty");- std::fs::create_dir_all(&pretty).unwrap();- std::fs::create_dir_all(bundle.join("3dmodels")).unwrap();- std::fs::write(pretty.join("R_0805_Adom.kicad_mod"), MOD_R).unwrap();- std::fs::write(pretty.join("Other.kicad_mod"), "(footprint \"Other\" (model \"${KICAD10_3DMODEL_DIR}/x.step\"))").unwrap();- std::fs::write(bundle.join("3dmodels/R_0805.step"), b"ISO-10303-21;").unwrap();- let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&pretty), "libraryType": "footprint", "description": "Adom wiki Cloud Library"}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["data"]["name"], json!("Cloud"));- assert_eq!(r["data"]["modelsInstalled"], json!(1));- assert_eq!(r["data"]["modelRefsRewritten"], json!(1));- let user = bx.ctx.user_dir.clone().unwrap();- let installed = user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod");- let text = std::fs::read_to_string(&installed).unwrap();- let model = norm_path(&user.join("3dmodels/Cloud.3dshapes/R_0805.step"));- assert!(text.contains(&format!("(model \"{model}\"")), "{text}");- // The stock ref and the SOURCE are untouched.- assert!(std::fs::read_to_string(user.join("footprints/Cloud.pretty/Other.kicad_mod")).unwrap().contains("${KICAD10_3DMODEL_DIR}"));- assert!(std::fs::read_to_string(pretty.join("R_0805_Adom.kicad_mod")).unwrap().contains("3dmodels/R_0805.step"));- let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();- assert_eq!(t.get("Cloud").unwrap().descr, "Adom wiki Cloud Library");- // A single .kicad_mod is wrapped in <name>.pretty and renamed after its declared name.- let one = bx.root.join("misnamed.kicad_mod");- std::fs::write(&one, MOD_R).unwrap();- let step = bx.root.join("body.step");- std::fs::write(&step, b"ISO-10303-21;").unwrap();- let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&one), "libraryType": "footprint", "libraryName": "Solo", "model3dPath": norm_path(&step)}));- assert_eq!(r["success"], json!(true), "{r}");- let f = user.join("footprints/Solo.pretty/R_0805_Adom.kicad_mod");- assert!(f.is_file());- let text = std::fs::read_to_string(&f).unwrap();- assert!(text.contains("3dmodels/Solo.3dshapes/body.step"), "{text}");- assert!(!text.contains("3dmodels/R_0805.step"));- assert!(r["data"]["model3dPath"].as_str().unwrap().ends_with("Solo.3dshapes/body.step"));- let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&bx.root.join("body.step")), "libraryType": "footprint"}));- assert!(r["error"].as_str().unwrap().starts_with("Invalid footprint source"));- }-- /// A stored (method 0) zip built by hand: enough for the reader in pcm.rs.- fn stored_zip(files: &[(&str, &[u8])]) -> Vec<u8> {- fn crc32(data: &[u8]) -> u32 {- let mut c = 0xFFFF_FFFFu32;- for &b in data {- c ^= b as u32;- for _ in 0..8 {- c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };- }- }- !c- }- let mut out = Vec::new();- let mut cd = Vec::new();- for (name, data) in files {- let off = out.len() as u32;- let crc = crc32(data);- out.extend_from_slice(b"PK\x03\x04");- out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);- out.extend_from_slice(&crc.to_le_bytes());- out.extend_from_slice(&(data.len() as u32).to_le_bytes());- out.extend_from_slice(&(data.len() as u32).to_le_bytes());- out.extend_from_slice(&(name.len() as u16).to_le_bytes());- out.extend_from_slice(&0u16.to_le_bytes());- out.extend_from_slice(name.as_bytes());- out.extend_from_slice(data);- cd.extend_from_slice(b"PK\x01\x02");- cd.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);- cd.extend_from_slice(&crc.to_le_bytes());- cd.extend_from_slice(&(data.len() as u32).to_le_bytes());- cd.extend_from_slice(&(data.len() as u32).to_le_bytes());- cd.extend_from_slice(&(name.len() as u16).to_le_bytes());- cd.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);- cd.extend_from_slice(&off.to_le_bytes());- cd.extend_from_slice(name.as_bytes());- }- let cd_off = out.len() as u32;- out.extend_from_slice(&cd);- out.extend_from_slice(b"PK\x05\x06");- out.extend_from_slice(&[0, 0, 0, 0]);- out.extend_from_slice(&(files.len() as u16).to_le_bytes());- out.extend_from_slice(&(files.len() as u16).to_le_bytes());- out.extend_from_slice(&(cd.len() as u32).to_le_bytes());- out.extend_from_slice(&cd_off.to_le_bytes());- out.extend_from_slice(&[0, 0]);- out- }-- #[test]- fn bundle_installs_symbols_footprints_and_models() {- let bx = fake_box("bundle");- let zip = stored_zip(&[- ("Cloud/Cloud.kicad_sym", SYM_R.as_bytes()),- ("Cloud/Cloud.pretty/R_0805_Adom.kicad_mod", MOD_R.as_bytes()),- ("Cloud/3dmodels/R_0805.step", b"ISO-10303-21;"),- ("Cloud/3dmodels/", b""),- ]);- let zp = bx.root.join("Cloud.zip");- std::fs::write(&zp, &zip).unwrap();- let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "capture": true}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["data"]["libraryName"], json!("Cloud"));- assert_eq!(r["data"]["symbols"], json!(1));- assert_eq!(r["data"]["footprints"], json!(1));- assert_eq!(r["data"]["modelsInstalled"], json!(1));- assert_eq!(r["data"]["modelRefsRewritten"], json!(1));- // Unknown (null) where the OS cannot list processes; false on a host with no KiCad running.- assert!(matches!(r["data"]["kicadRunning"], Value::Null | Value::Bool(false)), "{r}");- if r["data"]["kicadRunning"].is_null() {- assert_eq!(r["data"]["restartNeeded"], json!(true));- }- assert!(r["proofSkipped"].as_str().unwrap().contains("window-phase"));- let user = bx.ctx.user_dir.clone().unwrap();- assert!(user.join("symbols/Cloud.kicad_sym").is_file());- assert!(user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod").is_file());- assert!(user.join("3dmodels/Cloud.3dshapes/R_0805.step").is_file());- let cfg = bx.ctx.config_dir.clone().unwrap();- assert!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().has_library("Cloud"));- assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("Cloud"));- // Guards.- assert_eq!(install_library_bundle(&bx.ctx, true, &json!({}))["error"], json!("No zipPath specified"));- assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "libraryName": "-bad"}))["error"].as_str().unwrap().contains("not usable"));- let evil = bx.root.join("evil.zip");- std::fs::write(&evil, stored_zip(&[("../escape.kicad_sym", b"x")])).unwrap();- let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&evil)}));- assert!(r["error"].as_str().unwrap().contains("unsafe zip entry"), "{r}");- let nosym = bx.root.join("nosym.zip");- std::fs::write(&nosym, stored_zip(&[("readme.txt", b"x")])).unwrap();- assert_eq!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&nosym)}))["error"], json!("No .kicad_sym found in the bundle"));- let notzip = bx.root.join("not.zip");- std::fs::write(¬zip, b"hello").unwrap();- assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(¬zip)}))["error"].as_str().unwrap().starts_with("Could not unpack the bundle"));- }-- #[test]- fn heal_merge_repoint_and_no_split() {- let bx = fake_box("healsplit");- let cfg = bx.ctx.config_dir.clone().unwrap();- let user = bx.ctx.user_dir.clone().unwrap();- // No split yet: nothing to do.- std::fs::create_dir_all(&cfg).unwrap();- let r = heal_split(&bx.ctx, "merge", false);- assert_eq!(r["success"], json!(true));- assert_eq!(r["healed"], json!(false));- assert_eq!(r["reason"], json!("no split"));- assert_eq!(r["split"], json!(false));- // Now split: the row points elsewhere, the default file has an orphan symbol- // plus a trailing block after the closer (the old appender bug).- let reg = bx.root.join("elsewhere/Adom.kicad_sym");- std::fs::create_dir_all(reg.parent().unwrap()).unwrap();- std::fs::write(®, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n\t(symbol \"R_Adom\" (property \"Reference\" \"R\" (at 0 0 0)))\n)\n").unwrap();- let mut t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();- t.remove_library("Adom");- t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(®), "", ""));- t.write_file(&cfg.join("sym-lib-table")).unwrap();- std::fs::create_dir_all(user.join("symbols")).unwrap();- let orphan = user.join("symbols/Adom.kicad_sym");- std::fs::write(&orphan, format!("{}(symbol \"Trail\" (property \"Reference\" \"T\" (at 0 0 0)))\n(symbol \"Broken\" (x\n", SYM_C)).unwrap();- let dry = heal_split(&bx.ctx, "merge", true);- assert_eq!(dry["success"], json!(true), "{dry}");- assert_eq!(dry["dryRun"], json!(true));- assert_eq!(dry["wouldAdd"], json!(["C_Adom", "Trail"]));- assert_eq!(dry["unparseableInSource"], json!(["Broken"]));- assert_eq!(list_symbols(®).len(), 1);- let r = heal_split(&bx.ctx, "merge", false);- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["healed"], json!(true));- assert_eq!(r["added"], json!(["C_Adom", "Trail"]));- assert_eq!(r["failed"], json!([]));- assert_eq!(r["registeredSymbolsAfter"], json!(3));- assert_eq!(list_symbols(®), vec!["R_Adom".to_string(), "C_Adom".to_string(), "Trail".to_string()]);- assert!(reg.with_extension("kicad_sym.bak").is_file());- // The orphan is untouched.- assert!(std::fs::read_to_string(&orphan).unwrap().contains("Broken"));- // Unknown mode.- let r = heal_split(&bx.ctx, "nuke", false);- assert_eq!(r["success"], json!(false));- assert_eq!(r["allowed"], json!(["merge", "repoint", "prune"]));- // Repoint: dry run leaves the row, the real run rewrites it.- let r = heal_split(&bx.ctx, "repoint", true);- assert_eq!(r["healed"], json!(false));- assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(®));- let r = heal_split(&bx.ctx, "repoint", false);- assert_eq!(r["healed"], json!(true));- assert_eq!(r["rowNowPointsAt"], json!(norm_path(&orphan)));- assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(&orphan));- // Prune without kicad-cli: an honest refusal, nothing written.- let r = heal_split(&bx.ctx, "prune", false);- assert_eq!(r["success"], json!(false));- assert_eq!(r["kicadParser"]["available"], json!(false));- assert!(!orphan.with_extension("kicad_sym.pruned.bak").exists());- }-- #[test]- fn make_part_project_and_export_part() {- let bx = fake_box("proj");- assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "openEditor": false}))["success"], json!(true));- let step = bx.root.join("R_0805_Adom.step");- std::fs::write(&step, b"ISO-10303-21;").unwrap();- assert_eq!(install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R), "openEditor": false, "model": {"filePath": norm_path(&step)}}))["success"], json!(true));-- let r = make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["projectName"], json!("r-adom-demo"));- assert_eq!(r["reference"], json!("R1"));- let proj = PathBuf::from(r["projectFile"].as_str().unwrap());- assert!(proj.ends_with("Documents/r-adom-demo/r-adom-demo.kicad_pro"), "{}", proj.display());- let sch = std::fs::read_to_string(r["schematicFile"].as_str().unwrap()).unwrap();- assert!(sch.contains("(symbol \"Adom:R_Adom\""));- assert!(sch.contains("(lib_id \"Adom:R_Adom\") (at 127.0 80.0 0)"));- assert!(sch.contains("(property \"Reference\" \"R1\" (at 127.0 72.5 0)"));- assert!(sch.contains("(property \"Footprint\" \"Adom:R_0805_Adom\""));- // Two pins (0, 3.81, 270) and (0, -3.81, 90): stub wires going away from the body.- assert!(sch.contains("(wire (pts (xy 127.0 76.19) (xy 127.0 70.19))"), "{sch}");- assert!(sch.contains("(wire (pts (xy 127.0 83.81) (xy 127.0 89.81))"), "{sch}");- assert_eq!(sch.matches('(').count(), sch.matches(')').count());- let pcb = std::fs::read_to_string(r["boardFile"].as_str().unwrap()).unwrap();- assert!(pcb.contains("(footprint \"Adom:R_0805_Adom\""));- assert!(pcb.contains("(layer \"F.Cu\")\n (at 108.0 68.0)"));- assert!(pcb.contains("(property \"Reference\" \"R1\""));- assert!(pcb.contains("(model \""));- assert!(pcb.contains("(gr_line (start 104.32 64.35) (end 111.68 64.35)"), "{pcb}");- assert!(pcb.contains("(segment (start 107.088 68.0) (end 105.12 68.0)"), "{pcb}");- assert!(pcb.contains("(segment (start 108.912 68.0) (end 110.88 68.0)"), "{pcb}");- assert_eq!(pcb.matches('(').count(), pcb.matches(')').count());- let pro = std::fs::read_to_string(&proj).unwrap();- assert_eq!(pro, "{\"board\":{},\"meta\":{\"filename\":\"r-adom-demo.kicad_pro\",\"version\":1},\"schematic\":{},\"sheets\":[]}");- // Errors.- assert_eq!(make_part_project(&bx.ctx, &json!({}))["error"], json!("partName required"));- assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "Nope"}))["error"], json!("Symbol 'Nope' not in the Adom library"));- assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "Nope"}))["error"], json!("Footprint 'Nope' not in Adom.pretty"));-- let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["errors"], json!([]));- assert!(r["outputDir"].as_str().unwrap().ends_with("Documents/adom-part-exports/R_Adom"));- assert_eq!(r["exports"]["symbol"]["single"], json!(true));- assert_eq!(r["exports"]["symbol"]["symbolsInLibrary"], json!(["R_Adom"]));- assert_eq!(r["exports"]["footprint"]["single"], json!(true));- assert!(r["exports"]["footprint"]["path"].as_str().unwrap().ends_with("R_Adom-footprint-only.pretty/R_0805_Adom.kicad_mod"));- assert!(r["exports"]["model"]["path"].as_str().unwrap().ends_with("R_Adom-model-only.step"));- assert_eq!(r["exports"]["model"]["bytes"], json!(13));- assert_eq!(r["output"], json!("one-off exports for R_Adom: footprint, model, symbol"));- let one = std::fs::read_to_string(r["exports"]["symbol"]["path"].as_str().unwrap()).unwrap();- assert!(one.starts_with("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n"));- // Partial: only the symbol, and a missing part reports errors with success:false.- let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "what": "symbol", "outputDir": norm_path(&bx.root.join("exp"))}));- assert_eq!(r["success"], json!(true));- assert_eq!(r["exports"].as_object().unwrap().len(), 1);- let r = export_part(&bx.ctx, &json!({"partName": "Nope"}));- assert_eq!(r["success"], json!(false));- assert_eq!(r["errors"].as_array().unwrap().len(), 3);- assert_eq!(export_part(&bx.ctx, &json!({}))["error"], json!("partName required"));- }-- #[test]- fn a_bundle_plan_lists_steps_and_slots_without_installing() {- let d = std::env::temp_dir().join(format!("adom-bundle-plan-{}", std::process::id()));- let _ = std::fs::remove_dir_all(&d);- std::fs::create_dir_all(d.join("Lib.pretty")).unwrap();- std::fs::create_dir_all(d.join("3dmodels")).unwrap();- std::fs::write(d.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20241209)\n (symbol \"AAA\" (property \"Footprint\" \"Lib:FP_A\" (at 0 0 0)))\n (symbol \"BBB\" (property \"Footprint\" \"Lib:FP_B\" (at 0 0 0)))\n (symbol \"CCC\" (property \"Footprint\" \"\" (at 0 0 0))))").unwrap();- std::fs::write(d.join("Lib.pretty/FP_A.kicad_mod"), "(footprint \"FP_A\" (model \"${ADOM}/3dmodels/fp_a.step\"))").unwrap();- std::fs::write(d.join("Lib.pretty/FP_B.kicad_mod"), "(footprint \"FP_B\")").unwrap();- std::fs::write(d.join("3dmodels/fp_a.step"), "ISO-10303-21;").unwrap();- let p = bundle_plan(&d.join("Lib.kicad_sym"), Some(&d.join("Lib.pretty")), Some(&d.join("3dmodels")), true);- let ev = p["expectedEvidence"].as_array().unwrap();- assert_eq!(ev[0]["slots"], json!(["symbol", "footprint", "3d"]), "{p}");- assert_eq!(ev[1]["slots"], json!(["symbol", "footprint"]));- assert_eq!(ev[2]["slots"], json!(["symbol"]));- let ids: Vec<&str> = p["steps"].as_array().unwrap().iter().filter_map(|s| s["id"].as_str()).collect();- assert_eq!(ids, vec!["unpack", "symbols", "footprints", "models", "register", "frame"]);- assert_eq!(p["libraryFrame"], 1);- assert!(p["totalEtaSec"].as_f64().unwrap() > 0.0);- let _ = std::fs::remove_dir_all(&d);- }-- #[test]- fn footprint_text_helpers() {- assert_eq!(footprint_name_in(MOD_R).as_deref(), Some("R_0805_Adom"));- assert_eq!(footprint_name_in("(module \"X\")"), None);- assert_eq!(pads(MOD_R), vec![(-0.9125, 0.0), (0.9125, 0.0)]);- assert_eq!(pins(SYM_R), vec![(0.0, 3.81, 270.0), (0.0, -3.81, 90.0)]);- assert!(coords(MOD_R).contains(&(-1.68, -0.95)));- let refs = model_refs(MOD_R);- assert_eq!(refs.len(), 1);- assert_eq!(refs[0].1, "3dmodels/R_0805.step");- assert_eq!(py_float(127.0), "127.0");- assert_eq!(py_float(72.5), "72.5");- assert_eq!(py_float(-0.9125), "-0.9125");- assert!(name_ok("Adom Cloud_1.0-x") && !name_ok(" x") && !name_ok("") && !name_ok(&"a".repeat(65)));- let w = wire_model_into_footprint("(footprint \"X\"\n (model \"old.step\" (offset (xyz 0 0 0)))\n)\n", "C:/new.step");- assert!(w.contains("(model \"C:/new.step\" (offset"), "{w}");- assert!(!w.contains("old.step"));- }-- // ---- uninstall_library ------ fn uninstall_ctx(root: &Path) -> LibCtx {- let cfg = root.join("cfg"); let user = root.join("user");- std::fs::create_dir_all(&cfg).unwrap();- std::fs::create_dir_all(user.join("footprints").join("Mine.pretty")).unwrap();- std::fs::create_dir_all(user.join("3dmodels").join("Mine.3dshapes")).unwrap();- std::fs::write(user.join("footprints").join("Mine.pretty").join("a.kicad_mod"), "(footprint \"a\")").unwrap();- let fp_uri = user.join("footprints").join("Mine.pretty").to_string_lossy().replace('\\', "/");- std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n (version 7)\n (lib (name \"Mine\")(type \"KiCad\")(uri \"{fp_uri}\")(options \"\")(descr \"\"))\n (lib (name \"Elsewhere\")(type \"KiCad\")(uri \"{}/outside.pretty\")(options \"\")(descr \"\"))\n (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${{KICAD10_FOOTPRINT_DIR}}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n", root.to_string_lossy().replace('\\', "/"))).unwrap();- std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n)\n").unwrap();- std::fs::create_dir_all(root.join("outside.pretty")).unwrap();- LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() }- }-- #[test]- fn uninstall_removes_row_and_only_managed_files() {- let root = unique_temp_dir("kb-uninstall-a");- let ctx = uninstall_ctx(&root);- let r = uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "deleteFiles": true}));- assert_eq!(r["success"], json!(true), "{r}");- assert_eq!(r["removed"].as_array().unwrap().len(), 1);- assert!(!ctx.user_dir.as_ref().unwrap().join("footprints").join("Mine.pretty").exists());- assert!(!ctx.user_dir.as_ref().unwrap().join("3dmodels").join("Mine.3dshapes").exists());- let table = LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap();- assert!(!table.has_library("Mine") && table.has_library("Elsewhere") && table.has_library("Resistor_SMD"));-- // registered in place, outside the managed folders: unregistered, files kept- let r = uninstall_library(&ctx, true, &json!({"libraryName": "Elsewhere", "deleteFiles": true}));- assert_eq!(r["success"], json!(true), "{r}");- assert!(root.join("outside.pretty").exists());- assert_eq!(r["filesKept"].as_array().unwrap().len(), 1);- assert_eq!(r["tableReloadNeeded"], json!(true));- let _ = std::fs::remove_dir_all(&root);- }-- #[test]- fn uninstall_never_removes_the_nested_stock_table_and_respects_the_folder_boundary() {- let root = unique_temp_dir("kb-uninstall-c");- let ctx = uninstall_ctx(&root);- let cfg = ctx.config_dir.clone().unwrap();- let user = ctx.user_dir.clone().unwrap();- // a fresh KiCad 10 box: ONE row of type Table carries every stock library- let sibling = user.join("footprints-old").join("Sib.pretty");- std::fs::create_dir_all(&sibling).unwrap();- let sib_uri = sibling.to_string_lossy().replace('\\', "/");- std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n (version 7)\n (lib (name \"KiCad\")(type \"Table\")(uri \"C:/Program Files/KiCad/10.0/share/kicad/template/fp-lib-table\")(options \"\")(descr \"KiCad Default Libraries\"))\n (lib (name \"Sib\")(type \"KiCad\")(uri \"{sib_uri}\")(options \"\")(descr \"\"))\n)\n")).unwrap();- let r = uninstall_library(&ctx, false, &json!({"libraryName": "KiCad", "deleteFiles": true}));- assert_eq!(r["errorCode"], json!("stock_library_protected"), "{r}");- assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("KiCad"));- // "footprints-old" shares a string prefix with "footprints" but is not inside it: files stay- let r = uninstall_library(&ctx, false, &json!({"libraryName": "Sib", "deleteFiles": true}));- assert_eq!(r["success"], json!(true), "{r}");- assert!(sibling.is_dir() && r["filesDeleted"].as_array().unwrap().is_empty());- let _ = std::fs::remove_dir_all(&root);- }-- #[test]- fn uninstall_refuses_adom_stock_and_unknown() {- let root = unique_temp_dir("kb-uninstall-b");- let ctx = uninstall_ctx(&root);- assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Adom"}))["errorCode"], json!("adom_library_protected"));- assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Resistor_SMD"}))["errorCode"], json!("stock_library_protected"));- assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Nope"}))["errorCode"], json!("library_not_registered"));- assert_eq!(uninstall_library(&ctx, false, &json!({}))["errorCode"], json!("library_name_required"));- assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "libraryType": "banana"}))["errorCode"], json!("bad_library_type"));- assert!(LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap().has_library("Resistor_SMD"));- let _ = std::fs::remove_dir_all(&root);- }-}+ let bx = fake_box("heal");
rust/crates/kicad-core/src/pcb.rs+2−1774@@ -1,1785 +1,13 @@-//! `.kicad_pcb` reading and copper editing on disk. Port of `parsers/pcb.py` and the pure-//! parts of `handlers/route.py` (pads in board coordinates, net resolution, s-expression-//! emission, the atomic write with `.adom-bak`) plus the route planner that-//! `handlers/live_routing.py::_plan` and `handlers/route.py::handle_route` share.-//!-//! Coordinates are millimetres in KiCad's board frame: +x right, +y DOWN. Nothing here-//! talks to KiCad; the IPC group (`crate::ipc`) and the file verbs build on it.--use std::collections::HashMap;-use std::path::{Path, PathBuf};--use serde::Serialize;-use serde_json::{json, Map, Value};--use crate::model_check::{parse_sexpr, Sx};-use crate::schematic::new_uuid;--pub const DEFAULT_WIDTH: f64 = 0.2;-pub const DEFAULT_VIA_SIZE: f64 = 0.6;-pub const DEFAULT_VIA_DRILL: f64 = 0.3;-pub const DEFAULT_LAYER: &str = "F.Cu";-/// First KiCad file format that names nets instead of numbering them (KiCad 10).-pub const NAME_BASED_NET_VERSION: i64 = 20260206;--// ----------------------------------------------------------------------------// Errors-// -----------------------------------------------------------------------------/// The Python `RoutingError`: a code, a message and extra detail keys that land in the-/// response next to `errorCode` and `error`.-#[derive(Clone, Debug, PartialEq)]-pub struct RoutingError {- pub code: String,- pub message: String,- pub detail: Map<String, Value>,-}--impl RoutingError {- pub fn new(code: &str, message: impl Into<String>) -> Self {- RoutingError { code: code.into(), message: message.into(), detail: Map::new() }- }- pub fn with(mut self, key: &str, value: Value) -> Self {- self.detail.insert(key.into(), value);- self- }-}--impl std::fmt::Display for RoutingError {- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {- write!(f, "{}: {}", self.code, self.message)- }-}--// ----------------------------------------------------------------------------// Parsed board-// -----------------------------------------------------------------------------#[derive(Clone, Debug, Default, Serialize)]-pub struct Layer {- pub number: String,- pub name: String,- #[serde(rename = "type")]- pub kind: String,- #[serde(skip_serializing_if = "Option::is_none")]- pub canonical_name: Option<String>,-}--#[derive(Clone, Debug, Default, Serialize, PartialEq)]-pub struct Net {- pub number: Option<i64>,- pub name: String,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Position {- pub x: f64,- pub y: f64,- #[serde(skip_serializing_if = "Option::is_none")]- pub rotation: Option<f64>,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Point {- pub x: f64,- pub y: f64,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct FpPad {- pub number: String,- #[serde(rename = "type")]- pub kind: String,- pub shape: String,- #[serde(skip_serializing_if = "Option::is_none")]- pub x: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub y: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub width: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub height: Option<f64>,- /// The pad's `(at x y angle)` angle: KiCad writes it absolute (footprint orientation included).- #[serde(skip_serializing_if = "Option::is_none")]- pub rotation: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub layers: Option<Vec<String>>,- /// Present when the pad carries a (net ...) reference at all (either dialect).- #[serde(skip_serializing_if = "Option::is_none")]- pub net_number: Option<Option<i64>>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net_name: Option<String>,-}--impl FpPad {- fn has_net(&self) -> bool {- self.net_number.is_some() || self.net_name.is_some()- }-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Footprint {- pub footprint: String,- pub layer: Option<String>,- pub uuid: Option<String>,- pub position: Option<Position>,- pub locked: bool,- pub properties: HashMap<String, String>,- pub reference: String,- pub value: String,- #[serde(skip_serializing_if = "Option::is_none")]- pub path: Option<String>,- pub sheetname: String,- pub sheetfile: String,- pub attr: String,- pub pad_count: usize,- pub pads: Vec<FpPad>,- /// Courtyard vertices in footprint-local mm (every `fp_line`, `fp_rect`, `fp_poly`,- /// `fp_circle` and `fp_arc` on F.CrtYd or B.CrtYd), empty when the footprint has no- /// courtyard. Not serialised: `crate::placement` turns it into a board-frame box.- #[serde(skip)]- pub courtyard: Vec<Point>,- /// The courtyard's actual outline as edges in footprint-local mm (lines, rectangle and- /// polygon sides, arcs and circles as short chords). Wiki #125: overlap is decided on- /// these, not on the bounding box of `courtyard`.- #[serde(skip)]- pub courtyard_edges: Vec<(Point, Point)>,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Segment {- pub uuid: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub start: Option<Point>,- #[serde(skip_serializing_if = "Option::is_none")]- pub end: Option<Point>,- #[serde(skip_serializing_if = "Option::is_none")]- pub width: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub layer: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net: Option<i64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net_name: Option<String>,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Via {- pub uuid: Option<String>,- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]- pub kind: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub position: Option<Point>,- #[serde(skip_serializing_if = "Option::is_none")]- pub size: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub drill: Option<f64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub layers: Option<Vec<String>>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net: Option<i64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net_name: Option<String>,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Zone {- pub uuid: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net: Option<i64>,- #[serde(skip_serializing_if = "Option::is_none")]- pub net_name: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub layer: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub layers: Option<Vec<String>>,- #[serde(skip_serializing_if = "Option::is_none")]- pub name: Option<String>,- #[serde(skip_serializing_if = "Option::is_none")]- pub priority: Option<i64>,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct Dimensions {- pub min_x: f64,- pub min_y: f64,- pub max_x: f64,- pub max_y: f64,- pub width: f64,- pub height: f64,-}--/// Which net dialect the board speaks: `Number` (KiCad <= 9, `(net 3)`) or `Name`-/// (KiCad 10, `(net "GND")`).-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]-#[serde(rename_all = "lowercase")]-pub enum NetFormat {- Number,- Name,-}--impl NetFormat {- pub fn as_str(self) -> &'static str {- match self {- NetFormat::Number => "number",- NetFormat::Name => "name",- }- }-}--#[derive(Clone, Debug, Serialize)]-pub struct Board {- pub file: String,- pub version: Option<String>,- pub generator: Option<String>,- pub general: Map<String, Value>,- pub paper: Option<String>,- pub title_block: Map<String, Value>,- pub layers: Vec<Layer>,- pub nets: Vec<Net>,- pub footprints: Vec<Footprint>,- pub segments: Vec<Segment>,- pub vias: Vec<Via>,- pub zones: Vec<Zone>,- pub dimensions: Option<Dimensions>,- pub net_format: NetFormat,-}--// ----------------------------------------------------------------------------// Parsing-// -----------------------------------------------------------------------------fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {- sx.atom_at(i)?.parse::<f64>().ok()-}--fn value_f64(sx: Option<&Sx>) -> Option<f64> {- sx?.value()?.parse::<f64>().ok()-}--fn value_string(sx: Option<&Sx>) -> Option<String> {- sx?.value().map(str::to_string)-}--fn string_atoms(sx: &Sx) -> Vec<String> {- sx.as_list().map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default()-}--/// Read a `(net ...)` reference in either board format (`_net_ref`).-fn net_ref(node: Option<&Sx>, name_based: bool) -> (Option<i64>, Option<String>) {- let Some(node) = node else { return (None, None) };- let Some(first) = node.atom_at(1) else { return (None, None) };- if name_based {- return (None, Some(first.to_string()));- }- let digits = first.strip_prefix('-').unwrap_or(first);- if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {- let name = node.atom_at(2).map(str::to_string);- return (first.parse::<i64>().ok(), name);- }- (None, Some(first.to_string()))-}--/// Parse board text already in memory (`parse_pcb_text`). The routing verbs splice new-/// copper into the TEXT they hold, so re-reading the file would race their own write.-pub fn parse_pcb_text(text: &str, filepath: &str) -> Result<Board, String> {- let tree = parse_sexpr(text).ok_or_else(|| "Empty S-expression".to_string())?;- if tree.as_list().is_none() {- return Err("not an s-expression document".into());- }- Ok(resolve_net_format(extract_raw(&tree, filepath)))-}--pub fn parse_pcb(path: &Path) -> Result<Board, String> {- let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;- parse_pcb_text(&text, &path.to_string_lossy())-}--fn extract_raw(tree: &Sx, filepath: &str) -> Board {- let version = value_string(tree.find("version"));- let name_based = version.as_deref().and_then(|v| v.parse::<i64>().ok()).unwrap_or(0) >= NAME_BASED_NET_VERSION;- Board {- file: filepath.to_string(),- version,- generator: value_string(tree.find("generator")),- general: extract_general(tree.find("general")),- paper: value_string(tree.find("paper")),- title_block: extract_title_block(tree.find("title_block")),- layers: extract_layers(tree),- nets: extract_nets(tree, name_based),- footprints: extract_footprints(tree, name_based),- segments: extract_segments(tree, name_based),- vias: extract_vias(tree, name_based),- zones: extract_zones(tree, name_based),- dimensions: extract_dimensions(tree),- net_format: NetFormat::Number,- }-}--/// `_resolve_net_format`: a KiCad 10 board has no net table, so its net inventory is the-/// names its pads and copper mention.-fn resolve_net_format(mut data: Board) -> Board {- let numbered = !data.nets.is_empty()- || data.footprints.iter().flat_map(|f| f.pads.iter()).any(|p| matches!(p.net_number, Some(Some(_))));- data.net_format = if numbered { NetFormat::Number } else { NetFormat::Name };- if data.nets.is_empty() {- let mut names: Vec<String> = Vec::new();- let mut push = |n: Option<&String>| {- if let Some(n) = n {- if !n.is_empty() && !names.contains(n) {- names.push(n.clone());- }- }- };- for fp in &data.footprints {- for p in &fp.pads {- push(p.net_name.as_ref());- }- }- for s in &data.segments {- push(s.net_name.as_ref());- }- for v in &data.vias {- push(v.net_name.as_ref());- }- for z in &data.zones {- push(z.net_name.as_ref());- }- data.nets = names.into_iter().map(|name| Net { number: None, name }).collect();- }- data-}--fn extract_general(node: Option<&Sx>) -> Map<String, Value> {- let mut out = Map::new();- if let Some(node) = node {- if let Some(t) = value_f64(node.find("thickness")) {- out.insert("thickness".into(), json!(t));- }- }- out-}--fn extract_title_block(node: Option<&Sx>) -> Map<String, Value> {- let mut out = Map::new();- let Some(node) = node else { return out };- for field in ["title", "date", "rev", "company"] {- if let Some(v) = value_string(node.find(field)).filter(|v| !v.is_empty()) {- out.insert(field.into(), json!(v));- }- }- for c in node.find_all("comment") {- if let (Some(n), Some(text)) = (c.atom_at(1), c.atom_at(2)) {- out.insert(format!("comment_{n}"), json!(text));- }- }- out-}--fn extract_layers(tree: &Sx) -> Vec<Layer> {- let Some(layers) = tree.find("layers").and_then(Sx::as_list) else { return Vec::new() };- layers- .iter()- .skip(1)- .filter_map(|child| {- let l = child.as_list()?;- if l.len() < 3 {- return None;- }- Some(Layer {- number: l[0].as_atom().unwrap_or("").to_string(),- name: l[1].as_atom().unwrap_or("").to_string(),- kind: l[2].as_atom().unwrap_or("").to_string(),- canonical_name: l.get(3).and_then(Sx::as_atom).map(str::to_string),- })- })- .collect()-}--fn extract_nets(tree: &Sx, name_based: bool) -> Vec<Net> {- tree.find_all("net")- .filter_map(|n| {- let (num, name) = net_ref(Some(n), name_based);- num.map(|number| Net { number: Some(number), name: name.unwrap_or_default() })- })- .collect()-}--fn extract_position(node: &Sx) -> Option<Position> {- let at = node.find("at")?;- let x = atom_f64(at, 1)?;- let y = atom_f64(at, 2)?;- Some(Position { x, y, rotation: atom_f64(at, 3) })-}--fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {- let mut out = Vec::new();- for node in tree.find_all("footprint") {- let Some(list) = node.as_list() else { continue };- if list.len() < 2 {- continue;- }- let mut props: HashMap<String, String> = HashMap::new();- for p in node.find_all("property") {- if let (Some(k), Some(v)) = (p.atom_at(1), p.atom_at(2)) {- props.insert(k.to_string(), v.to_string());- }- }- let pads: Vec<FpPad> = node- .find_all("pad")- .filter_map(|pad| {- let l = pad.as_list()?;- if l.len() < 4 {- return None;- }- let at = pad.find("at");- let size = pad.find("size");- let (num, name) = net_ref(pad.find("net"), name_based);- let has_net = num.is_some() || name.is_some();- Some(FpPad {- number: pad.atom_at(1).unwrap_or("").to_string(),- kind: pad.atom_at(2).unwrap_or("").to_string(),- shape: pad.atom_at(3).unwrap_or("").to_string(),- x: at.and_then(|a| atom_f64(a, 1)),- y: at.and_then(|a| atom_f64(a, 2)),- width: size.and_then(|s| atom_f64(s, 1)),- height: size.and_then(|s| atom_f64(s, 2)),- rotation: at.and_then(|a| atom_f64(a, 3)),- layers: pad.find("layers").map(string_atoms),- net_number: if has_net { Some(num) } else { None },- net_name: if has_net { Some(name.unwrap_or_default()) } else { None },- })- })- .collect();- out.push(Footprint {- footprint: node.atom_at(1).unwrap_or("").to_string(),- layer: value_string(node.find("layer")),- uuid: value_string(node.find("uuid")),- position: extract_position(node),- locked: node.find("locked").is_some(),- reference: props.get("Reference").cloned().unwrap_or_default(),- value: props.get("Value").cloned().unwrap_or_default(),- properties: props,- path: value_string(node.find("path")),- sheetname: value_string(node.find("sheetname")).unwrap_or_default(),- sheetfile: value_string(node.find("sheetfile")).unwrap_or_default(),- attr: value_string(node.find("attr")).unwrap_or_default(),- pad_count: pads.len(),- pads,- courtyard: extract_courtyard(node),- courtyard_edges: extract_courtyard_edges(node),- });- }- out-}--/// The courtyard outline's vertices in footprint-local coordinates: a rectangle's four-/// corners, a circle's four extreme points, a line's ends, an arc's start, mid and end,-/// a polygon's points. A footprint with no F.CrtYd or B.CrtYd shape yields nothing.-fn extract_courtyard(fp: &Sx) -> Vec<Point> {- let mut out = Vec::new();- let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));- for node in fp.find_all("fp_line").chain(fp.find_all("fp_arc")) {- if !on_courtyard(node) {- continue;- }- for coord in ["start", "mid", "end"] {- if let Some(p) = point_of(node.find(coord)) {- out.push(p);- }- }- }- for node in fp.find_all("fp_rect") {- if !on_courtyard(node) {- continue;- }- if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {- out.push(Point { x: a.x, y: a.y });- out.push(Point { x: b.x, y: a.y });- out.push(Point { x: b.x, y: b.y });- out.push(Point { x: a.x, y: b.y });- }- }- for node in fp.find_all("fp_circle") {- if !on_courtyard(node) {- continue;- }- if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {- let r = dist((c.x, c.y), (e.x, e.y));- out.push(Point { x: c.x - r, y: c.y });- out.push(Point { x: c.x + r, y: c.y });- out.push(Point { x: c.x, y: c.y - r });- out.push(Point { x: c.x, y: c.y + r });- }- }- for node in fp.find_all("fp_poly") {- if !on_courtyard(node) {- continue;- }- if let Some(pts) = node.find("pts") {- for xy in pts.find_all("xy") {- if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {- out.push(Point { x, y });- }- }- }- }- out-}--/// The courtyard outline as edges (see `Footprint::courtyard_edges`).-fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {- let mut out: Vec<(Point, Point)> = Vec::new();- let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));- let pt = |x: f64, y: f64| Point { x, y };- for node in fp.find_all("fp_line") {- if on_courtyard(node) {- if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {- out.push((a, b));- }- }- }- for node in fp.find_all("fp_arc") {- if on_courtyard(node) {- if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {- // every 2 degrees, like the board model: two chords cut a large arc short (wiki #133)- let pts = arc_points(&a, &m, &b);- for w in pts.windows(2) {- out.push((w[0].clone(), w[1].clone()));- }- }- }- }- for node in fp.find_all("fp_rect") {- if on_courtyard(node) {- if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {- let c = [pt(a.x, a.y), pt(b.x, a.y), pt(b.x, b.y), pt(a.x, b.y)];- for i in 0..4 {- out.push((c[i].clone(), c[(i + 1) % 4].clone()));- }- }- }- }- for node in fp.find_all("fp_circle") {- if on_courtyard(node) {- if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {- let r = dist((c.x, c.y), (e.x, e.y));- // 2 degrees: a 24-gon's chords cut a 24 mm ring's hole 0.2 mm short (wiki #133)- let n = 180;- let at = |k: usize| {- let t = std::f64::consts::TAU * k as f64 / n as f64;- pt(c.x + r * t.cos(), c.y + r * t.sin())- };- for k in 0..n {- out.push((at(k), at(k + 1)));- }- }- }- }- for node in fp.find_all("fp_poly") {- if on_courtyard(node) {- if let Some(pts) = node.find("pts") {- let v: Vec<Point> = pts.find_all("xy").filter_map(|xy| Some(pt(atom_f64(xy, 1)?, atom_f64(xy, 2)?))).collect();- if v.len() >= 2 {- for i in 0..v.len() {- out.push((v[i].clone(), v[(i + 1) % v.len()].clone()));- }- }- }- }- }- out-}--/// A three-point arc as points every 2 degrees from start through mid to end (start and end exact);-/// collinear points are a straight line.-pub fn arc_points(a: &Point, m: &Point, b: &Point) -> Vec<Point> {- let d = 2.0 * (a.x * (m.y - b.y) + m.x * (b.y - a.y) + b.x * (a.y - m.y));- if d.abs() < 1e-12 {- return vec![a.clone(), b.clone()];- }- let (a2, m2, b2) = (a.x * a.x + a.y * a.y, m.x * m.x + m.y * m.y, b.x * b.x + b.y * b.y);- let cx = (a2 * (m.y - b.y) + m2 * (b.y - a.y) + b2 * (a.y - m.y)) / d;- let cy = (a2 * (b.x - m.x) + m2 * (a.x - b.x) + b2 * (m.x - a.x)) / d;- let r = dist((cx, cy), (a.x, a.y));- let ang = |p: &Point| (p.y - cy).atan2(p.x - cx);- let tau = std::f64::consts::TAU;- let (s, mid, e) = (ang(a), ang(m), ang(b));- let ccw = |x: f64, y: f64| (y - x).rem_euclid(tau);- // the sweep from start to end that passes through mid, signed- let sweep = if ccw(s, mid) < ccw(s, e) { ccw(s, e) } else { -(tau - ccw(s, e)) };- let n = ((sweep.abs().to_degrees() / 2.0).ceil() as usize).max(1);- let mut out: Vec<Point> = (0..=n).map(|k| {- let t = s + sweep * k as f64 / n as f64;- Point { x: cx + r * t.cos(), y: cy + r * t.sin() }- }).collect();- out[0] = a.clone();- out[n] = b.clone();- out-}--fn point_of(node: Option<&Sx>) -> Option<Point> {- let n = node?;- Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })-}--fn extract_segments(tree: &Sx, name_based: bool) -> Vec<Segment> {- tree.find_all("segment")- .map(|node| {- let (num, name) = net_ref(node.find("net"), name_based);- Segment {- uuid: value_string(node.find("uuid")),- start: point_of(node.find("start")),- end: point_of(node.find("end")),- width: value_f64(node.find("width")),- layer: value_string(node.find("layer")),- net: num,- net_name: name,- }- })- .collect()-}--fn extract_vias(tree: &Sx, name_based: bool) -> Vec<Via> {- tree.find_all("via")- .map(|node| {- let (num, name) = net_ref(node.find("net"), name_based);- Via {- uuid: value_string(node.find("uuid")),- kind: node.atom_at(1).map(str::to_string),- position: point_of(node.find("at")),- size: value_f64(node.find("size")),- drill: value_f64(node.find("drill")),- layers: node.find("layers").map(string_atoms),- net: num,- net_name: name,- }- })- .collect()-}--fn extract_zones(tree: &Sx, name_based: bool) -> Vec<Zone> {- tree.find_all("zone")- .map(|node| {- let (num, mut name) = net_ref(node.find("net"), name_based);- if let Some(n) = value_string(node.find("net_name")) {- name = Some(n);- }- Zone {- uuid: value_string(node.find("uuid")),- net: num,- net_name: name,- layer: value_string(node.find("layer")),- layers: node.find("layers").map(string_atoms),- name: value_string(node.find("name")),- priority: node.find("priority").and_then(Sx::value).and_then(|v| v.parse::<i64>().ok()),- }- })- .collect()-}--fn extract_dimensions(tree: &Sx) -> Option<Dimensions> {- let mut min_x = f64::INFINITY;- let mut min_y = f64::INFINITY;- let mut max_x = f64::NEG_INFINITY;- let mut max_y = f64::NEG_INFINITY;- let mut found = false;- let mut update = |x: f64, y: f64| {- min_x = min_x.min(x);- min_y = min_y.min(y);- max_x = max_x.max(x);- max_y = max_y.max(y);- found = true;- };- let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");- for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {- for node in tree.find_all(tag) {- if !on_edge(node) {- continue;- }- for coord in ["start", "end", "mid"] {- if let Some(p) = point_of(node.find(coord)) {- update(p.x, p.y);- }- }- // an arc reaches as far as its sweep goes, a circle its radius all round (wiki #133:- // one 340 degree edge arc read only by its three points left the box 100 mm short)- if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {- for p in arc_points(&a, &m, &b) {- update(p.x, p.y);- }- }- if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {- let r = dist((c.x, c.y), (e.x, e.y));- update(c.x - r, c.y - r);- update(c.x + r, c.y + r);- }- if let Some(pts) = node.find("pts") {- for xy in pts.find_all("xy") {- if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {- update(x, y);- }- }- }- }- }- for fp in tree.find_all("footprint") {- let (fx, fy) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));- for tag in ["fp_line", "fp_rect", "fp_arc"] {- for node in fp.find_all(tag) {- if !on_edge(node) {- continue;- }- for coord in ["start", "end", "center", "mid"] {- if let Some(p) = point_of(node.find(coord)) {- update(p.x + fx, p.y + fy);- }- }- }- }- }- if !found {- return None;- }- let r3 = |v: f64| (v * 1000.0).round() / 1000.0;- Some(Dimensions { min_x: r3(min_x), min_y: r3(min_y), max_x: r3(max_x), max_y: r3(max_y), width: r3(max_x - min_x), height: r3(max_y - min_y) })-}--// ----------------------------------------------------------------------------// Numbers and geometry-// -----------------------------------------------------------------------------/// KiCad-style trimmed float (`1.5`, not `1.500000`).-pub fn num(v: f64) -> String {- let s = format!("{v:.6}");- let s = s.trim_end_matches('0').trim_end_matches('.');- if s.is_empty() || s == "-" || s == "-0" {- "0".into()- } else {- s.to_string()- }-}--pub fn round6(v: f64) -> f64 {- (v * 1_000_000.0).round() / 1_000_000.0-}--pub fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {- ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()-}--/// Python `_number`: a finite, in-range number rounded to KiCad's nanometre precision.-/// Booleans are refused (Python's `isinstance(True, int)` trap).-pub fn number(value: Option<&Value>, label: &str, positive: bool) -> Result<f64, RoutingError> {- let v = match value {- Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),- _ => return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number"))),- };- if !v.is_finite() {- return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number")));- }- if v.abs() > 2000.0 || (positive && v <= 0.0) {- return Err(RoutingError::new("invalid_geometry", format!("{label} is outside the supported board range")));- }- let rounded = round6(v);- if positive && rounded <= 0.0 {- return Err(RoutingError::new("invalid_geometry", format!("{label} is below KiCad nanometre precision")));- }- Ok(rounded)-}--// ----------------------------------------------------------------------------// Nets-// -----------------------------------------------------------------------------/// A net reference the writers can emit, in this board's dialect.-#[derive(Clone, Debug, PartialEq)]-pub struct NetRef {- pub format: NetFormat,- pub number: Option<i64>,- pub name: String,- /// The identity of the net on THIS board: its number pre-KiCad-10, else its name.- pub key: Value,-}--pub fn net_key(board: &Board, number: Option<i64>, name: Option<&str>) -> Value {- match board.net_format {- NetFormat::Number => number.map(|n| json!(n)).unwrap_or(Value::Null),- NetFormat::Name => json!(name.unwrap_or("")),- }-}--fn is_no_net(key: &Value) -> bool {- match key {- Value::Null => true,- Value::Number(n) => n.as_f64() == Some(0.0),- Value::String(s) => s.is_empty(),- _ => false,- }-}--/// `_resolve_net`: a name or (pre-KiCad-10) number to a reference this board can carry.-/// Never invents one. Err carries the ready-to-return response.-pub fn resolve_net(board: &Board, spec: Option<&Value>) -> Result<NetRef, Value> {- let fmt = board.net_format;- let names: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).collect();- let spec = spec.cloned().unwrap_or(Value::Null);- let spec = if spec.is_null() { json!("") } else { spec };- match &spec {- Value::String(s) => {- if let Some(n) = board.nets.iter().find(|n| &n.name == s) {- return Ok(NetRef { format: fmt, number: n.number, name: s.clone(), key: net_key(board, n.number, Some(s)) });- }- }- Value::Number(n) => {- if let Some(f) = n.as_f64().filter(|f| f.is_finite() && *f == f.trunc()) {- let i = f as i64;- if fmt == NetFormat::Name && i == 0 {- return Ok(NetRef { format: fmt, number: None, name: String::new(), key: json!("") });- }- if fmt == NetFormat::Number {- if let Some(n) = board.nets.iter().find(|n| n.number == Some(i)) {- return Ok(NetRef { format: fmt, number: Some(i), name: n.name.clone(), key: json!(i) });- }- }- }- }- _ => {}- }- Err(json!({- "success": false, "errorCode": "unknown_net", "error": format!("No exact net matching {}", py_repr(&spec)),- "nets": names.iter().take(60).collect::<Vec<_>>(),- "_hint": "Use an exact net name. Integer net codes are supported only for pre-KiCad-10 boards.",- }))-}--/// Python `repr()` for the values that reach error messages.-fn py_repr(v: &Value) -> String {- match v {- Value::String(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),- Value::Bool(true) => "True".into(),- Value::Bool(false) => "False".into(),- Value::Null => "None".into(),- other => other.to_string(),- }-}--/// The `(net ...)` line in this board's dialect, or nothing for deliberately unconnected-/// copper on a KiCad 10 board (which has no net 0 to name).-pub fn net_sexpr(net: &NetRef) -> String {- match net.format {- NetFormat::Number => format!("\t\t(net {})\n", net.number.unwrap_or(0)),- NetFormat::Name => {- if net.name.is_empty() {- String::new()- } else {- format!("\t\t(net {})\n", serde_json::to_string(&net.name).unwrap_or_default())- }- }- }-}--/// Match the board's own copper: this net's width, else the commonest width, else 0.2.-pub fn default_width(board: &Board, net_key: Option<&Value>) -> f64 {- let commonest = |widths: Vec<f64>| -> Option<f64> {- let mut counts: Vec<(f64, usize)> = Vec::new();- for w in widths {- match counts.iter_mut().find(|(k, _)| *k == w) {- Some(c) => c.1 += 1,- None => counts.push((w, 1)),- }- }- counts.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))).map(|c| c.0)- };- let on_net: Vec<f64> = board- .segments- .iter()- .filter(|s| s.width.map(|w| w != 0.0).unwrap_or(false))- .filter(|s| net_key.map(|k| net_key_of_segment(board, s) == *k).unwrap_or(true))- .filter_map(|s| s.width)- .collect();- if let Some(w) = commonest(on_net) {- return w;- }- let any: Vec<f64> = board.segments.iter().filter_map(|s| s.width).filter(|w| *w != 0.0).collect();- commonest(any).unwrap_or(DEFAULT_WIDTH)-}--fn net_key_of_segment(board: &Board, s: &Segment) -> Value {- net_key(board, s.net, s.net_name.as_deref())-}--// ----------------------------------------------------------------------------// Pads in board coordinates-// -----------------------------------------------------------------------------/// One pad as `kicad_board_pads` reports it: board millimetres with footprint rotation-/// applied, +y DOWN, plus its net.-#[derive(Clone, Debug, Serialize, PartialEq)]-pub struct Pad {- #[serde(rename = "ref")]- pub reference: String,- pub pad: String,- pub name: String,- pub x: f64,- pub y: f64,- pub net: Option<i64>,- #[serde(rename = "netName")]- pub net_name: String,- #[serde(rename = "netKey")]- pub net_key: Value,- #[serde(rename = "type")]- pub kind: String,- pub layers: Vec<String>,- pub side: String,- pub footprint: String,-}--/// 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.-pub fn pads(board: &Board) -> Vec<Pad> {- let mut out = Vec::new();- for fp in &board.footprints {- let (fx, fy, rot) = fp.position.as_ref().map(|p| (p.x, p.y, p.rotation.unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));- let ang = rot.to_radians();- let (sin_a, cos_a) = ang.sin_cos();- for pad in &fp.pads {- let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };- let net_number = pad.net_number.flatten();- let net_name = pad.net_name.clone().unwrap_or_default();- out.push(Pad {- reference: fp.reference.clone(),- pad: pad.number.clone(),- name: format!("{}.{}", fp.reference, pad.number),- x: round6(fx + px * cos_a + py * sin_a),- y: round6(fy - px * sin_a + py * cos_a),- net: net_number,- net_key: if pad.has_net() { net_key(board, net_number, Some(&net_name)) } else { net_key(board, None, None) },- net_name,- kind: pad.kind.clone(),- layers: pad.layers.clone().unwrap_or_default(),- side: fp.layer.clone().unwrap_or_default(),- footprint: fp.footprint.clone(),- });- }- }- out-}--/// Wiki #119, measured with KiCad's own DRC on KiCad 10.0.2 and 10.0.5: a footprint on `B.Cu` whose-/// SMD pads say `F.Cu` keeps those pads on the FRONT copper at their literal positions ("Pad 1 [N1] of R1-/// on F.Cu" in KiCad's connectivity report; back-layer tracks connect to them neither at the literal nor-/// at the mirrored spot). The bridge reads them the same way, which is right. Such a footprint is-/// almost always a part placed on the back by a tool that did not flip it the way KiCad does (KiCad's-/// own flip moves the pads to `B.*`: its StickHub demo, 123 of 123 back-side pads). This finds them so-/// replies can say so, instead of letting routing fail on copper that is really on the other side.-pub fn back_footprint_with_front_pads(fp: &Footprint) -> bool {- if fp.layer.as_deref() != Some("B.Cu") {- return false;- }- fp.pads.iter().any(|p| {- let l = p.layers.clone().unwrap_or_default();- p.kind == "smd" && l.iter().any(|x| x == "F.Cu") && !l.iter().any(|x| x == "B.Cu" || x == "*.Cu")- })-}--/// References of every footprint `back_footprint_with_front_pads` finds, for a reply warning.-pub fn back_footprints_with_front_pads(board: &Board) -> Vec<String> {- board.footprints.iter().filter(|f| back_footprint_with_front_pads(f)).map(|f| f.reference.clone()).collect()-}--pub const FRONT_PADS_HINT: &str = "These footprints sit on B.Cu but their SMD pads are on F.Cu. KiCad keeps such pads on the FRONT copper at their stored positions (checked with KiCad's own DRC on 10.0.2 and 10.0.5), so back-layer copper cannot reach them and a via is checked against them where they really are. A footprint flipped in KiCad has its pads on B.Cu; one written by another tool must flip it the same way: move every pad layer F.* to B.* and negate each pad's local x (and mirror its angle), or flip the part in KiCad (select, F).";--pub fn copper_layers(board: &Board) -> Vec<String> {- board.layers.iter().filter(|l| l.name.ends_with(".Cu")).map(|l| l.name.clone()).collect()-}--/// 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 is a dangling track to DRC. Through-hole pads carry-/// the `*.Cu` wildcard.-pub fn pad_reaches(pad: Option<&Pad>, layer: &str) -> bool {- let Some(pad) = pad else { return true };- if pad.layers.is_empty() {- return true;- }- for l in &pad.layers {- if l == layer || l == "*.Cu" || (l.ends_with('*') && layer.starts_with(&l[..l.len() - 1])) {- return true;- }- }- !pad.layers.iter().any(|l| l.ends_with(".Cu") || l == "*.Cu")-}--/// The net a pad sits on, or None for a pad with no net (a mounting hole, a fiducial).-pub fn pad_net(board: &Board, pad: Option<&Pad>) -> Option<NetRef> {- let pad = pad?;- if is_no_net(&pad.net_key) {- return None;- }- Some(NetRef { format: board.net_format, number: pad.net, name: pad.net_name.clone(), key: pad.net_key.clone() })-}--/// `'R1.2' / 'R1-2' / 'R1 2'` -> that pad.-pub fn find_pad<'a>(pads: &'a [Pad], spec: &str) -> Result<&'a Pad, Value> {- let key = spec.trim().replace('-', ".").replace(' ', ".").to_uppercase();- if let Some(p) = pads.iter().find(|p| p.name.to_uppercase() == key) {- return Ok(p);- }- let reference = key.split('.').next().unwrap_or("").to_string();- let near: Vec<&str> = pads.iter().filter(|p| p.reference.to_uppercase() == reference).map(|p| p.name.as_str()).collect();- let candidates: Vec<&str> = if near.is_empty() { pads.iter().map(|p| p.name.as_str()).collect() } else { near };- Err(json!({- "success": false, "error": format!("no pad '{spec}' on this board"), "errorCode": "unknown_pad",- "candidates": candidates.iter().take(20).collect::<Vec<_>>(),- "_hint": "Pads are addressed 'REF.PAD' (e.g. 'U1.7'). kicad_board_pads lists every pad with its board coordinates.",- }))-}--/// `(x, y)` and the pad it came from. Accepts `[x,y]`, `{x,y}`, `"REF.PAD"` or `{"pad":"REF.PAD"}`.-pub fn resolve_point<'a>(spec: &Value, pads: &'a [Pad]) -> Result<((f64, f64), Option<&'a Pad>), Value> {- let as_f64 = |v: &Value| -> Option<f64> {- match v {- Value::Number(n) => n.as_f64(),- Value::String(s) => s.trim().parse::<f64>().ok(),- _ => None,- }- };- match spec {- Value::String(s) => {- let pad = find_pad(pads, s)?;- return Ok(((pad.x, pad.y), Some(pad)));- }- Value::Object(o) => {- if let Some(p) = o.get("pad").filter(|p| truthy(p)) {- let name = match p {- Value::String(s) => s.clone(),- other => other.to_string(),- };- let pad = find_pad(pads, &name)?;- return Ok(((pad.x, pad.y), Some(pad)));- }- if let (Some(x), Some(y)) = (o.get("x"), o.get("y")) {- if let (Some(x), Some(y)) = (as_f64(x), as_f64(y)) {- return Ok(((x, y), None));- }- }- }- Value::Array(a) if a.len() >= 2 => {- if let (Some(x), Some(y)) = (as_f64(&a[0]), as_f64(&a[1])) {- return Ok(((x, y), None));- }- }- _ => {}- }- Err(json!({- "success": false, "error": format!("could not read a point from {}", py_repr(spec)), "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.",- }))-}--fn truthy(v: &Value) -> bool {- match v {- Value::Null => false,- Value::Bool(b) => *b,- Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),- Value::String(s) => !s.is_empty(),- Value::Array(a) => !a.is_empty(),- Value::Object(o) => !o.is_empty(),- }-}--// ----------------------------------------------------------------------------// s-expression emission-// -----------------------------------------------------------------------------pub fn segment_sexpr(a: (f64, f64), b: (f64, f64), width: f64, layer: &str, net: &NetRef) -> String {- format!(- "\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)",- num(a.0),- num(a.1),- num(b.0),- num(b.1),- num(width),- layer,- net_sexpr(net),- new_uuid()- )-}--pub fn via_sexpr(at: (f64, f64), size: f64, drill: f64, layers: (&str, &str), net: &NetRef) -> String {- format!(- "\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"{}\" \"{}\")\n{}\t\t(uuid \"{}\")\n\t)",- num(at.0),- num(at.1),- num(size),- num(drill),- layers.0,- layers.1,- net_sexpr(net),- new_uuid()- )-}--/// Insert board items before the closing paren (where KiCad keeps copper).-/// The board text without the top-level item whose uuid is `id` (a segment or an arc).-pub fn text_without_item(text: &str, id: &str) -> Option<String> {- let root = crate::sxedit::parse(text).ok()?;- let item = root.kids.iter().find(|k| matches!(k.tag(), Some("segment" | "arc")) && k.uuid() == Some(id))?;- let mut start = item.start;- while start > 0 && matches!(text.as_bytes()[start - 1], b' ' | b'\t') {- start -= 1;- }- if start > 0 && text.as_bytes()[start - 1] == b'\n' {- start -= 1;- }- Some(format!("{}{}", &text[..start], &text[item.end..]))-}--pub fn splice(text: &str, blocks: &[String]) -> String {- let trimmed = text.trim_end();- let idx = trimmed.rfind(')').unwrap_or(trimmed.len());- format!("{}\n{}\n)\n", text[..idx].trim_end(), blocks.join("\n"))-}--// ----------------------------------------------------------------------------// Route planning (shared by kicad_route and kicad_route_net)-// -----------------------------------------------------------------------------#[derive(Clone, Debug, Serialize)]-pub struct PlanSegment {- pub start: [f64; 2],- pub end: [f64; 2],- pub layer: String,- pub width: f64,-}--#[derive(Clone, Debug, Serialize)]-pub struct PlanVia {- pub at: [f64; 2],- pub layers: [String; 2],- pub size: f64,- pub drill: f64,-}--/// The validated copper for one net: what the caller sees (`segments`, `vias`) and the-/// s-expression blocks the DRC snapshot and the file writer splice in.-#[derive(Clone, Debug)]-pub struct Plan {- pub net_name: String,- pub segments: Vec<PlanSegment>,- pub vias: Vec<PlanVia>,- pub blocks: Vec<String>,-}--impl Plan {- /// `{"netName", "segments", "vias"}` (everything but `blocks`).- pub fn public(&self) -> Value {- json!({"netName": self.net_name, "segments": self.segments, "vias": self.vias})- }-}--/// Pure planning and validation (`live_routing._plan`); every waypoint is checked before-/// any mutation. `args`: net, points or paths, width, layer, viaSize, viaDrill.-pub fn plan(board: &Board, args: &Value) -> Result<Plan, RoutingError> {- let netname = match args.get("net") {- Some(Value::String(s)) if !s.is_empty() => s.clone(),- _ => return Err(RoutingError::new("missing_net", "net must be an exact, nonempty net name")),- };- let matches: Vec<&Net> = board.nets.iter().filter(|n| n.name == netname).collect();- if matches.len() != 1 {- return Err(RoutingError::new("unknown_net", format!("No unique net named '{netname}'")));- }- let net = NetRef { format: board.net_format, number: matches[0].number, name: netname.clone(), key: net_key(board, matches[0].number, Some(&netname)) };- let pads = pads(board);- let copper = copper_layers(board);- let width = number(Some(args.get("width").unwrap_or(&json!(0.25))), "width", true)?;- let size = number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;- let drill = number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;- if drill >= size {- return Err(RoutingError::new("invalid_geometry", "viaDrill must be smaller than viaSize"));- }- let paths: Vec<Value> = match args.get("paths") {- Some(Value::Array(a)) if !a.is_empty() && a.len() <= 128 => a.clone(),- Some(_) => return Err(RoutingError::new("invalid_path", "Supply points or up to 128 paths for one net")),- None => vec![args.get("points").cloned().unwrap_or(Value::Null)],- };- let mut segments = Vec::new();- let mut vias = Vec::new();- let mut blocks = Vec::new();- for path in &paths {- let Some(path) = path.as_array().filter(|p| (2..=512).contains(&p.len())) else {- return Err(RoutingError::new("invalid_path", "Each path needs 2..512 waypoints"));- };- // (point, pad, explicit layer)- let mut resolved: Vec<((f64, f64), Option<&Pad>, Option<String>)> = Vec::new();- for spec in path {- let coordinates: Option<[Option<&Value>; 2]> = match spec {- Value::Array(a) => {- if a.len() != 2 {- return Err(RoutingError::new("invalid_geometry", "Coordinates require exactly [x, y]"));- }- Some([a.first(), a.get(1)])- }- Value::Object(o) if !o.get("pad").map(truthy).unwrap_or(false) => Some([o.get("x"), o.get("y")]),- _ => None,- };- if let Some(c) = coordinates {- for v in c {- number(v, "coordinate", false)?;- }- }- let (point, pad) = resolve_point(spec, &pads).map_err(|e| {- RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), e.get("error").and_then(Value::as_str).unwrap_or("bad point").to_string())- })?;- let point = (number(Some(&json!(point.0)), "coordinate", false)?, number(Some(&json!(point.1)), "coordinate", false)?);- if let Some(p) = pad {- if p.net_name != netname {- return Err(RoutingError::new("net_mismatch", format!("Pad {} is not on {netname}", p.name)));- }- }- let layer = spec.get("layer").and_then(Value::as_str).map(str::to_string);- if let Some(l) = &layer {- if !copper.contains(l) {- return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {l}")));- }- }- resolved.push((point, pad, layer));- }- let mut layer: String = match args.get("layer").and_then(Value::as_str) {- Some(l) => l.to_string(),- None => resolved[0]- .2- .clone()- .or_else(|| resolved[0].1.map(|p| p.side.clone()).filter(|s| !s.is_empty()))- .unwrap_or_else(|| DEFAULT_LAYER.to_string()),- };- if !copper.contains(&layer) {- return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")));- }- for i in 0..resolved.len() {- let (point, pad, explicit) = (&resolved[i].0, resolved[i].1, resolved[i].2.clone());- let next_layer = explicit.unwrap_or_else(|| layer.clone());- if i > 0 {- let previous = resolved[i - 1].0;- if previous != *point {- segments.push(PlanSegment { start: [previous.0, previous.1], end: [point.0, point.1], layer: layer.clone(), width });- blocks.push(segment_sexpr(previous, *point, width, &layer, &net));- }- }- // A layer marker changes the outgoing layer AT this point, including the final- // waypoint (where a via can reach a back-side pad).- if next_layer != layer {- vias.push(PlanVia { at: [point.0, point.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });- blocks.push(via_sexpr(*point, size, drill, ("F.Cu", "B.Cu"), &net));- layer = next_layer;- }- if let Some(p) = pad {- if !(p.layers.iter().any(|l| l == &layer) || p.layers.iter().any(|l| l == "*.Cu")) {- return Err(RoutingError::new("pad_layer_mismatch", format!("Pad {} has no copper on {layer}", p.name)));- }- }- }- }- if segments.is_empty() && vias.is_empty() {- return Err(RoutingError::new("zero_length", "No nonzero segments or layer transitions"));- }- if segments.len() + vias.len() > 1024 {- return Err(RoutingError::new("route_too_large", "Limit each call to 1024 copper items"));- }- Ok(Plan { net_name: netname, segments, vias, blocks })-}--// ----------------------------------------------------------------------------// The board on disk-// -----------------------------------------------------------------------------/// KiCad's own open-file marker, `~<name>.kicad_pcb.lck` beside the board. The one signal-/// that works with no window list at all.-pub fn lock_file(path: &Path) -> Option<PathBuf> {- let name = path.file_name()?.to_string_lossy().to_string();- let lck = path.with_file_name(format!("~{name}.lck"));- lck.exists().then_some(lck)-}--/// A lock file no KiCad holds any more (KiCad killed or crashed) blocks every file edit and makes-/// KiCad itself ask about it on the next open. Remove it only when it is certainly stale: the OS layer-/// lists no KiCad process at all, and the lock was written on this machine (a board on a shared drive-/// may be open elsewhere). Anything uncertain keeps the lock and the board_open refusal.-fn clear_stale_lock(path: &Path) {- let Some(lck) = lock_file(path) else { return }; if crate::install_kicad::kicad_running().0 != Some(false) { return; }- let here = this_hostname();+ let here = std::env::var("COMPUTERNAME").or_else(|_| std::env::var("HOSTNAME")).unwrap_or_default(); let text = std::fs::read_to_string(&lck).unwrap_or_default(); let host = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["hostname"].as_str().map(str::to_string)); // a lock that names no machine is KiCad's own on this one; one that names a machine must name this one- if host.map_or(true, |h| !here.is_empty() && h.eq_ignore_ascii_case(&here)) {- let _ = std::fs::remove_file(&lck); } } -/// This machine's name as KiCad writes it into a lock file. Windows exports COMPUTERNAME; on Linux-/// HOSTNAME is a shell variable a service never sees, so the kernel's own name is read instead.-pub fn this_hostname() -> String {- std::env::var("COMPUTERNAME")- .or_else(|_| std::env::var("HOSTNAME"))- .ok()- .filter(|h| !h.is_empty())- .or_else(|| std::fs::read_to_string("/proc/sys/kernel/hostname").ok().map(|h| h.trim().to_string()))- .unwrap_or_default()-}- /// A loaded board: path, the exact text on disk and the parse of it. #[derive(Debug)]-pub struct Loaded {- pub path: PathBuf,- pub text: String,- pub board: Board,-}--/// `route._load`: read and parse a board file, or the ready-to-return error. Without-/// `read_only` an open board (lock file present) is refused before any write.-pub fn load(args: &Value, read_only: bool) -> Result<Loaded, Value> {- let raw = ["filePath", "boardPath"].iter().find_map(|k| args.get(*k).and_then(Value::as_str)).filter(|s| !s.is_empty());- let Some(raw) = raw else {- return Err(json!({- "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.",- }));- };- let path = PathBuf::from(raw);- if !read_only {- clear_stale_lock(&path);- }- if !read_only && lock_file(&path).is_some() {- return Err(json!({- "success": false, "errorCode": "board_open",- "error": "This board is open in KiCad; file routing would conflict with live edits",- "_hint": "Use kicad_routing_state and kicad_route_net for live edits, or close this board before offline routing.",- }));- }- let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();- if !name.to_ascii_lowercase().ends_with(".kicad_pcb") {- return Err(json!({- "success": false, "error": format!("not a board file: {name}"), "errorCode": "not_a_board",- "_hint": "These verbs edit a .kicad_pcb. For a schematic there is nothing to route.",- }));- }- if !path.exists() {- return Err(json!({- "success": false, "error": format!("board not found: {}", path.display()), "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.",- }));- }- let text = match std::fs::read_to_string(&path) {- Ok(t) => t,- Err(e) => {- return Err(json!({"success": false, "error": format!("could not read board: {e}"), "errorCode": "board_unreadable", "_hint": "Is the file locked by another process?"}))- }- };- if !text.trim_start().starts_with("(kicad_pcb") {- return Err(json!({- "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.",- }));- }- let board = match parse_pcb_text(&text, &path.to_string_lossy()) {- Ok(b) => b,- Err(e) => {- return Err(json!({- "success": false, "error": format!("could not parse board: {e}"), "errorCode": "board_unparsable",- "_hint": "Our reader is not KiCad's parser. kicad_lint_board gets KiCad's own verdict.",- }))- }- };- Ok(Loaded { path, text, board })-}--/// Validate the new copper, refuse live or changed boards, back up to `<path>.adom-bak`-/// and replace the file through a unique temp file (`route._write`). Returns the backup-/// path, or the ready-to-return `board_write_refused` response.-pub fn write_board(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, Value> {- match write_board_inner(path, new_text, backup, expected) {- Ok(bak) => Ok(bak),- Err(e) => Err(json!({- "success": false, "error": format!("Board write refused: {e}"), "errorCode": "board_write_refused",- "_hint": "No board replacement was performed. Reinspect geometry and the live editor before retrying.",- })),- }-}--fn write_board_inner(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, String> {- let file = path.to_string_lossy().to_string();- let data = parse_pcb_text(new_text, &file)?;- let old_ids: Vec<String> = match expected {- Some(exp) => {- let original = parse_pcb_text(exp, &file)?;- original.segments.iter().filter_map(|s| s.uuid.clone()).chain(original.vias.iter().filter_map(|v| v.uuid.clone())).collect()- }- None => Vec::new(),- };- let is_old = |uuid: &Option<String>| uuid.as_ref().map(|u| old_ids.contains(u)).unwrap_or(false);- for s in &data.segments {- if is_old(&s.uuid) {- continue;- }- let (Some(w), Some(a), Some(b)) = (s.width, &s.start, &s.end) else {- return Err("Track coordinates must be finite and width must be positive".into());- };- if ![w, a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) || w <= 0.0 {- return Err("Track coordinates must be finite and width must be positive".into());- }- }- for v in &data.vias {- if is_old(&v.uuid) {- continue;- }- let (Some(size), Some(drill), Some(p)) = (v.size, v.drill, &v.position) else {- return Err("Via geometry must be finite, with 0 < drill < size".into());- };- if ![size, drill, p.x, p.y].iter().all(|x| x.is_finite()) || !(0.0 < drill && drill < size) {- return Err("Via geometry must be finite, with 0 < drill < size".into());- }- let layers = v.layers.clone().unwrap_or_default();- let mut sorted = layers.clone();- sorted.sort();- sorted.dedup();- if sorted != ["B.Cu".to_string(), "F.Cu".to_string()] {- return Err("Only through vias spanning F.Cu to B.Cu are supported".into());- }- }- if lock_file(path).is_some() {- return Err("Board was opened while planning; use live routing".into());- }- if let Some(exp) = expected {- let current = std::fs::read_to_string(path).map_err(|e| e.to_string())?;- if current != exp {- return Err("Board changed while planning; inspect it and retry".into());- }- }- let mut bak = None;- if backup {- let b = format!("{}.adom-bak", path.to_string_lossy());- std::fs::copy(path, &b).map_err(|e| e.to_string())?;- bak = Some(b);- }- let dir = path.parent().unwrap_or(Path::new("."));- let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();- let tmp = dir.join(format!("{name}.{}.{}.adom-tmp", std::process::id(), new_uuid().split('-').next().unwrap_or("x")));- let result = (|| -> Result<(), String> {- {- use std::io::Write;- let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;- f.write_all(new_text.as_bytes()).map_err(|e| e.to_string())?;- f.sync_all().map_err(|e| e.to_string())?;- }- std::fs::rename(&tmp, path).map_err(|e| e.to_string())- })();- let _ = std::fs::remove_file(&tmp);- result?;- Ok(bak)-}--// ----------------------------------------------------------------------------// Tests-// -----------------------------------------------------------------------------#[cfg(test)]-pub(crate) mod tests {- use super::*;--- fn qfn_board(pad_layers: &str) -> Board {- let pad = |n: &str, x: f64, y: f64| format!("(pad \"{n}\" smd roundrect (at {x} {y}) (size 0.2 0.8) (layers {pad_layers}) (net 1 \"GND\"))");- let text = format!("(kicad_pcb (version 20250907) (generator \"t\") (net 0 \"\") (net 1 \"GND\")\n\- (footprint \"Package_DFN_QFN:QFN-56\" (layer \"B.Cu\") (at 52 153)\n\- (property \"Reference\" \"U1\" (at 0 0) (layer \"B.SilkS\"))\n {}\n {}\n))",- pad("19", -1.0, 3.44), pad("24", 1.0, 3.44));- parse_pcb_text(&text, "t.kicad_pcb").unwrap()- }-- #[test]- fn a_back_side_footprint_with_front_pads_is_read_as_kicad_reads_it_and_flagged() {- // KiCad 10.0.2 and 10.0.5 keep these pads on F.Cu at the literal position (wiki #119).- let b = qfn_board("\"F.Cu\" \"F.Mask\" \"F.Paste\"");- let p = pads(&b);- let p19 = p.iter().find(|x| x.pad == "19").unwrap();- assert_eq!((p19.x, p19.y), (51.0, 156.44));- assert!(pad_reaches(Some(p19), "F.Cu") && !pad_reaches(Some(p19), "B.Cu"));- assert_eq!(back_footprints_with_front_pads(&b), vec!["U1".to_string()]);- }-- #[test]- fn a_footprint_flipped_by_kicad_itself_is_read_literally() {- // KiCad's own flip writes the pads on the back layers; those positions are already right- // (StickHub demo: 123 of 123 back-side pads match their own net's copper this way).- let b = qfn_board("\"B.Cu\" \"B.Mask\" \"B.Paste\"");- let p = pads(&b);- let p19 = p.iter().find(|x| x.pad == "19").unwrap();- let p24 = p.iter().find(|x| x.pad == "24").unwrap();- assert_eq!((p19.x, p19.y), (51.0, 156.44));- assert_eq!((p24.x, p24.y), (53.0, 156.44));- assert!(pad_reaches(Some(p19), "B.Cu"));- assert!(back_footprints_with_front_pads(&b).is_empty());- }-- fn repo_root() -> PathBuf {- PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()- }-- fn rp2040() -> (String, Board) {- let p = repo_root().join("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb");- let text = std::fs::read_to_string(&p).expect("template board present");- let b = parse_pcb_text(&text, &p.to_string_lossy()).unwrap();- (text, b)- }-- /// The Python `demo/routing/make_fixture.py` board: three through-hole connectors on- /// six named nets, KiCad 10 dialect.- pub(crate) fn fixture_text() -> String {- let base = std::fs::read_to_string(repo_root().join("templates/blank-board.kicad_pcb")).unwrap();- let mut blocks = String::new();- let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {- blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));- blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));- blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");- for (n, px, py, net) in pads {- blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));- }- blocks.push_str(")\n");- };- let six = |_: ()| (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();- footprint("J1", 105.0, 85.0, six(()));- footprint("J2", 145.0, 85.0, six(()));- footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);- blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));- let idx = base.rfind(')').unwrap();- format!("{}{}\n)\n", &base[..idx], blocks)- }-- fn test_board() -> Board {- // The tests/test_live_routing.py `board()` fixture as s-expression text (KiCad 10 dialect).- let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")- (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "B.Cu" signal) (5 "F.SilkS" user))- (footprint "X:J1" (layer "F.Cu") (at 10 20 90) (property "Reference" "J1")- (pad "1" thru_hole circle (at 2 0) (size 1 1) (layers "*.Cu") (net "GND"))- (pad "2" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VCC")))- (footprint "X:J2" (layer "B.Cu") (at 30 20) (property "Reference" "J2")- (pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu") (net "GND")))- (footprint "X:J3" (layer "F.Cu") (at 50 20) (property "Reference" "J3")- (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "123")))-)"#;- parse_pcb_text(text, "test.kicad_pcb").unwrap()- }-- #[test]- fn parses_the_rp2040_template_pads_layers_and_dimensions() {- let (_, b) = rp2040();- assert_eq!(b.version.as_deref(), Some("20240108"));- assert_eq!(b.net_format, NetFormat::Number, "the template carries a (net 0 \"\") table");- assert_eq!(b.nets, vec![Net { number: Some(0), name: String::new() }]);- assert_eq!(copper_layers(&b), vec!["F.Cu", "B.Cu"]);- let all = pads(&b);- assert_eq!(all.len(), 117);- let u1 = b.footprints.iter().find(|f| f.reference == "U1").unwrap();- assert_eq!(u1.footprint, "AdomRP2040:QFN-56_AdomRP2040");- assert_eq!((u1.position.as_ref().unwrap().x, u1.position.as_ref().unwrap().y), (130.0, 110.0));- let u1_1 = all.iter().find(|p| p.name == "U1.1").unwrap();- assert_eq!((u1_1.x, u1_1.y), (130.0 - 3.4375, 110.0 - 2.6));- assert_eq!(u1_1.layers, vec!["F.Cu", "F.Mask", "F.Paste"]);- assert_eq!(u1_1.side, "F.Cu");- assert!(b.segments.is_empty() && b.vias.is_empty());- assert!(b.dimensions.is_some() || b.footprints.len() > 1);- }-- #[test]- fn rp2040_add_track_and_via_round_trip_with_backup() {- let (text, b) = rp2040();- let dir = std::env::temp_dir().join(format!("adom-pcb-test-{}-{}", std::process::id(), new_uuid()));- std::fs::create_dir_all(&dir).unwrap();- let path = dir.join("rp2040-breakout.kicad_pcb");- std::fs::write(&path, &text).unwrap();- let net = resolve_net(&b, Some(&json!(0))).unwrap();- assert_eq!(net.format, NetFormat::Number);- let all = pads(&b);- let ((ax, ay), pad_a) = resolve_point(&json!("U1.1"), &all).unwrap();- assert!(pad_a.is_some());- let seg = segment_sexpr((ax, ay), (ax - 3.0, ay), 0.2, "F.Cu", &net);- let via = via_sexpr((ax - 3.0, ay), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);- let new_text = splice(&text, &[seg, via]);- let bak = write_board(&path, &new_text, true, Some(&text)).unwrap();- assert_eq!(std::fs::read_to_string(bak.as_ref().unwrap()).unwrap(), text);- let after = parse_pcb(&path).unwrap();- assert_eq!(after.segments.len(), 1);- assert_eq!(after.vias.len(), 1);- let s = &after.segments[0];- assert_eq!(s.net, Some(0));- assert_eq!(s.layer.as_deref(), Some("F.Cu"));- assert_eq!((s.start.as_ref().unwrap().x, s.end.as_ref().unwrap().x), (ax, ax - 3.0));- let v = &after.vias[0];- assert_eq!((v.size, v.drill), (Some(0.6), Some(0.3)));- assert_eq!(v.layers.as_ref().unwrap(), &["F.Cu", "B.Cu"]);- assert!(v.uuid.is_some() && s.uuid.is_some());- // Every pad survives the splice untouched.- assert_eq!(pads(&after), all);- // A changed file is refused (the text on disk no longer matches `expected`).- let err = write_board(&path, &new_text, false, Some(&text)).unwrap_err();- assert_eq!(err["errorCode"], "board_write_refused");- // A lock file marks an open board: one written on another machine (a shared drive) is always honoured.- std::fs::write(dir.join("~rp2040-breakout.kicad_pcb.lck"), r#"{"hostname":"ANOTHER-PC","username":"someone"}"#).unwrap();- assert!(lock_file(&path).is_some());- let err = load(&json!({"filePath": path.to_string_lossy()}), false).unwrap_err();- assert_eq!(err["errorCode"], "board_open");- assert!(load(&json!({"filePath": path.to_string_lossy()}), true).is_ok());- // One left behind on this machine by a KiCad that is no longer running is stale: cleared, the edit goes ahead.- if crate::install_kicad::kicad_running().0 == Some(false) {- std::fs::write(dir.join("~rp2040-breakout.kicad_pcb.lck"), "{}").unwrap();- assert!(load(&json!({"filePath": path.to_string_lossy()}), false).is_ok());- assert!(lock_file(&path).is_none());- }- let _ = std::fs::remove_dir_all(&dir);- }-- #[test]- fn numeric_net_name_in_kicad_10_file() {- let data = parse_pcb_text("(kicad_pcb (version 20260206) (footprint \"X\" (layer \"F.Cu\") (pad \"1\" thru_hole circle (net \"123\"))))", "x").unwrap();- assert_eq!(data.net_format, NetFormat::Name);- assert_eq!(data.nets, vec![Net { number: None, name: "123".into() }]);- }-- #[test]- fn numbered_dialect_reads_both_shapes() {- let data = parse_pcb_text(- "(kicad_pcb (version 20240108) (net 0 \"\") (net 3 \"GND\") (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (net 3 \"GND\"))) (segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net 3)))",- "x",- )- .unwrap();- assert_eq!(data.net_format, NetFormat::Number);- assert_eq!(data.nets.len(), 2);- assert_eq!(data.segments[0].net, Some(3));- assert_eq!(data.segments[0].net_name, None);- let p = &pads(&data)[0];- assert_eq!((p.net, p.net_name.as_str(), p.net_key.clone()), (Some(3), "GND", json!(3)));- assert_eq!(resolve_net(&data, Some(&json!("123"))).unwrap_err()["errorCode"], "unknown_net");- let b = parse_pcb_text("(kicad_pcb (net 1 \"123\"))", "x").unwrap();- assert_eq!(resolve_net(&b, Some(&json!("123"))).unwrap().number, Some(1));- for spec in [json!(1.5), json!(true), json!("123x")] {- assert!(resolve_net(&b, Some(&spec)).is_err(), "{spec}");- }- assert_eq!(resolve_net(&b, Some(&json!(1))).unwrap().name, "123");- }-- #[test]- fn rotated_pad_coordinates_and_final_layer_transition() {- let p = plan(&test_board(), &json!({"net": "GND", "points": ["J1.1", {"pad": "J2.1", "layer": "B.Cu"}]})).unwrap();- assert_eq!(p.segments[0].start, [10.0, 18.0]);- assert_eq!(p.vias[0].at, [30.0, 20.0]);- assert_eq!(p.vias[0].layers, ["F.Cu", "B.Cu"]);- assert_eq!(p.blocks.len(), 2);- }-- #[test]- fn branches_on_one_net() {- let p = plan(&test_board(), &json!({"net": "GND", "paths": [["J1.1", [20, 18]], [[20, 18], [20, 25]]]})).unwrap();- assert_eq!(p.segments.len(), 2);- }-- #[test]- fn reject_wrong_net_and_layer() {- for (args, code) in [- (json!({"net": "GND", "points": ["J1.1", "J1.2"]}), "net_mismatch"),- (json!({"net": "GND", "points": ["J1.1", "J2.1"]}), "pad_layer_mismatch"),- (json!({"net": "GND", "points": ["J1.1", {"x": 20, "y": 20, "layer": "F.SilkS"}]}), "unknown_layer"),- (json!({"net": "GND", "points": ["J1.1", "J9.1"]}), "unknown_pad"),- (json!({"net": "NOPE", "points": [[0, 0], [1, 1]]}), "unknown_net"),- (json!({"points": [[0, 0], [1, 1]]}), "missing_net"),- ] {- assert_eq!(plan(&test_board(), &args).unwrap_err().code, code, "{args}");- }- }-- #[test]- fn invalid_geometry_is_rejected_before_mutation() {- for extra in [- json!({"width": 0}),- json!({"width": -1}),- json!({"width": true}),- json!({"points": [[true, 0], [1, 1]]}),- json!({"points": [[0, 0, 9], [1, 1]]}),- json!({"viaDrill": 0.8, "viaSize": 0.6}),- json!({"points": [[0, 0], [5000, 0]]}),- json!({"points": [[0, 0], [0, 0]]}),- json!({"points": [[0, 0]]}),- json!({"paths": []}),- ] {- let mut args = json!({"net": "GND", "points": [[0, 0], [1, 1]]});- for (k, v) in extra.as_object().unwrap() {- args[k] = v.clone();- }- assert!(plan(&test_board(), &args).is_err(), "{extra}");- }- }-- #[test]- fn exact_numeric_net_name() {- assert_eq!(plan(&test_board(), &json!({"net": "123", "points": [[0, 0], [1, 1]]})).unwrap().net_name, "123");- }-- #[test]- fn quoted_net_serialization() {- let net = NetRef { format: NetFormat::Name, number: None, name: "a\"b\\c".into(), key: json!("a\"b\\c") };- assert!(net_sexpr(&net).contains("\"a\\\"b\\\\c\""));- let none = NetRef { format: NetFormat::Name, number: None, name: String::new(), key: json!("") };- assert_eq!(net_sexpr(&none), "");- let numbered = NetRef { format: NetFormat::Number, number: Some(4), name: "X".into(), key: json!(4) };- assert_eq!(net_sexpr(&numbered), "\t\t(net 4)\n");- }-- #[test]- fn num_formats_like_kicad() {- assert_eq!(num(1.5), "1.5");- assert_eq!(num(1.0), "1");- assert_eq!(num(0.0), "0");- assert_eq!(num(-0.0000001), "0");- assert_eq!(num(105.0), "105");- assert_eq!(num(0.25), "0.25");- }-- #[test]- fn fixture_routes_whole_plan_through_the_file_writer() {- let text = fixture_text();- let b = parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();- assert_eq!(b.net_format, NetFormat::Name);- assert_eq!(b.nets.len(), 6);- assert_eq!(pads(&b).len(), 13);- let p = plan(&b, &json!({"net": "NET_3", "width": 0.25, "points": ["J1.3", {"x": 112, "y": 95, "layer": "B.Cu"}, {"x": 138, "y": 95, "layer": "F.Cu"}, "J2.3"]})).unwrap();- assert_eq!(p.segments.len(), 3);- assert_eq!(p.vias.len(), 2);- assert_eq!(p.segments[1].layer, "B.Cu");- let spliced = splice(&text, &p.blocks);- let after = parse_pcb_text(&spliced, "x").unwrap();- assert_eq!(after.segments.len(), 3);- assert_eq!(after.vias.len(), 2);- assert_eq!(after.segments[0].net_name.as_deref(), Some("NET_3"));- assert_eq!(after.vias[0].net_name.as_deref(), Some("NET_3"));- assert_eq!(default_width(&after, None), 0.25);- }-- #[test]- fn pad_reach_and_pad_net() {- let b = test_board();- let all = pads(&b);- let j1_2 = all.iter().find(|p| p.name == "J1.2").unwrap();- assert!(pad_reaches(Some(j1_2), "F.Cu"));- assert!(!pad_reaches(Some(j1_2), "B.Cu"));- let j1_1 = all.iter().find(|p| p.name == "J1.1").unwrap();- assert!(pad_reaches(Some(j1_1), "B.Cu"));- assert_eq!(pad_net(&b, Some(j1_1)).unwrap().name, "GND");- assert!(find_pad(&all, "j1-2").is_ok());- assert!(find_pad(&all, "J1 1").is_ok());- let err = find_pad(&all, "J1.9").unwrap_err();- assert_eq!(err["candidates"].as_array().unwrap().len(), 2);- }-}+pub struct Loaded {
rust/crates/kicad-core/src/sch_render.rs+1−578@@ -1,583 +1,6 @@-//! Schematic to PNG and visual baselines.-//!-//! `kicad-cli sch export svg --output <dir> <root>` writes one SVG per sheet (the root as-//! `<stem>.svg`, sub-sheets as `<stem>-<sheet path>.svg`). The SVG is rasterised in-process-//! with resvg (pure Rust: usvg parses, tiny-skia paints), so no Inkscape, cairo or browser is-//! needed on the desktop. KiCad plots text as strokes (the `<text>` elements it adds are-//! invisible, for search), so resvg is built without its font stack.-//!-//! A baseline is a PNG plus a small JSON (width, source hash, renderer) under-//! `<project>/.adom/baselines/`. Compare renders at the baseline's width and reports the share-//! of pixels that changed and the bounding box of the change, in pixels and in sheet mm.-//!-//! The kicad-cli call goes through `render::Runner`, so the argv and the file pick are unit-//! tested with canned output and no KiCad.--use std::path::{Path, PathBuf};-use std::time::Duration;--use resvg::tiny_skia::{self, Pixmap};-use resvg::usvg;-use serde_json::{json, Value};--use crate::render::Runner;--#[derive(Clone, Debug)]-pub struct SvgOpts {- pub drawing_sheet: bool,- pub black_and_white: bool,- pub theme: Option<String>,-}--impl Default for SvgOpts {- fn default() -> Self {- SvgOpts { drawing_sheet: true, black_and_white: false, theme: None }- }-}--fn norm(p: &Path) -> String {- p.to_string_lossy().replace('\\', "/")-}--/// The kicad-cli argv (no program name): `sch export svg --output <dir> [...] <file>`.-pub fn svg_argv(sch: &Path, out_dir: &Path, o: &SvgOpts) -> Vec<String> {- let mut v: Vec<String> = vec!["sch".into(), "export".into(), "svg".into(), "--output".into(), norm(out_dir)];- if !o.drawing_sheet {- v.push("--exclude-drawing-sheet".into());- }- if o.black_and_white {- v.push("--black-and-white".into());- }- if let Some(t) = &o.theme {- v.push("--theme".into());- v.push(t.clone());- }- v.push(norm(sch));- v-}--/// Run the export into `out_dir` (emptied of old SVGs first) and list what it wrote.-pub fn export_svgs(run: Runner, sch: &Path, out_dir: &Path, o: &SvgOpts, timeout: Duration) -> Result<Vec<PathBuf>, String> {- std::fs::create_dir_all(out_dir).map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?;- for old in list_svgs(out_dir) {- let _ = std::fs::remove_file(old);- }- let r = run(&svg_argv(sch, out_dir, o), timeout);- let files = list_svgs(out_dir);- if r.timed_out {- return Err(format!("kicad-cli sch export svg timed out after {} s", timeout.as_secs()));- }- if files.is_empty() {- let tail: String = r.stderr.trim().chars().rev().take(400).collect::<Vec<_>>().into_iter().rev().collect();- return Err(format!("kicad-cli sch export svg wrote no SVG (exit {}): {}", r.exit_code, if tail.is_empty() { r.stdout.trim().to_string() } else { tail }));- }- Ok(files)-}--pub fn list_svgs(dir: &Path) -> Vec<PathBuf> {- let mut v: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|x| x.to_str()).map_or(false, |x| x.eq_ignore_ascii_case("svg"))).collect();- v.sort();- v-}--fn stem(p: &Path) -> String {- p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()-}--fn squash(s: &str) -> String {- s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()-}--/// Pick the SVG for one sheet. `sheet` is None for the root, else (sheet name, human path like-/// `/Power/Filter/`). Exact `<stem>-<path>` first, then a name suffix, then a substring.-pub fn pick_svg(files: &[PathBuf], root_stem: &str, sheet: Option<(&str, &str)>) -> Option<PathBuf> {- match sheet {- None => files.iter().find(|f| stem(f) == root_stem).cloned().or_else(|| files.iter().min_by_key(|f| stem(f).len()).cloned()),- Some((name, human)) => {- let path_part = human.trim_matches('/').replace('/', "-");- let exact = squash(&format!("{root_stem}-{path_part}"));- if let Some(f) = files.iter().find(|f| squash(&stem(f)) == exact) {- return Some(f.clone());- }- let n = squash(name);- files- .iter()- .filter(|f| stem(f) != root_stem)- .find(|f| squash(&stem(f)).ends_with(&n))- .or_else(|| files.iter().filter(|f| stem(f) != root_stem).find(|f| squash(&stem(f)).contains(&n)))- .cloned()- }- }-}--pub struct Raster {- pub pixmap: Pixmap,- pub width_mm: f64,- pub height_mm: f64,-}--/// Rasterise SVG bytes to `width` pixels wide (height follows the aspect), on white.-pub fn rasterize(svg: &[u8], width: u32) -> Result<Raster, String> {- if !(16..=16384).contains(&width) {- return Err(format!("width {width} is out of range (16 to 16384 px)"));- }- let tree = usvg::Tree::from_data(svg, &usvg::Options::default()).map_err(|e| format!("cannot parse the SVG: {e}"))?;- let size = tree.size();- let (w_px, h_px) = (size.width() as f64, size.height() as f64);- if w_px <= 0.0 || h_px <= 0.0 {- return Err("the SVG has no size".into());- }- let scale = width as f64 / w_px;- let height = (h_px * scale).round().max(1.0) as u32;- if (width as u64) * (height as u64) > 200_000_000 {- return Err(format!("{width} x {height} px is too large; pass a smaller width"));- }- let mut pixmap = Pixmap::new(width, height).ok_or("cannot allocate the image")?;- pixmap.fill(tiny_skia::Color::WHITE);- resvg::render(&tree, tiny_skia::Transform::from_scale(scale as f32, scale as f32), &mut pixmap.as_mut());- // usvg resolves mm to px at 96 dpi- Ok(Raster { pixmap, width_mm: w_px * 25.4 / 96.0, height_mm: h_px * 25.4 / 96.0 })-}--/// A proof frame of parts on a sheet: the plotted page cropped to `regions` (sheet mm, y down, [x0, y0, x1, y1]) plus-/// `margin_mm`, each region outlined, about `width` px wide. Returns the image and the framed region in sheet mm.-pub fn frame(svg: &[u8], regions: &[[f64; 4]], margin_mm: f64, width: u32) -> Result<(Pixmap, [f64; 4]), String> {- if regions.is_empty() {- return Err("nothing to frame".into());- }- let mut u = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];- for r in regions {- u = [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])];- }- let m = margin_mm.max(0.0);- let mut box_mm = [u[0] - m, u[1] - m, u[2] + m, u[3] + m];- // the page at a scale that makes the framed region `width` px wide, within the rasteriser's limit- let page = rasterize(svg, 64)?;- let crop_w = (box_mm[2] - box_mm[0]).max(1.0);- let full = ((width as f64) * page.width_mm / crop_w).round().clamp(64.0, 16384.0) as u32;- let raster = rasterize(svg, full)?;- let ppm = raster.pixmap.width() as f64 / raster.width_mm;- box_mm = [box_mm[0].max(0.0), box_mm[1].max(0.0), box_mm[2].min(raster.width_mm), box_mm[3].min(raster.height_mm)];- let px = |v: f64| (v * ppm).round().max(0.0) as i32;- let (x0, y0) = (px(box_mm[0]), px(box_mm[1]));- let (w, h) = ((px(box_mm[2]) - x0).max(1) as u32, (px(box_mm[3]) - y0).max(1) as u32);- let rect = tiny_skia::IntRect::from_xywh(x0, y0, w, h).ok_or("the framed region is empty")?;- let mut crop = raster.pixmap.clone_rect(rect).ok_or("the framed region is off the page")?;- let mut paint = tiny_skia::Paint::default();- paint.set_color_rgba8(220, 30, 30, 255);- paint.anti_alias = true;- let stroke = tiny_skia::Stroke { width: (0.15 * ppm as f32).max(2.0), ..Default::default() };- for r in regions {- let pad = 1.0;- let (rx, ry) = (((r[0] - pad) * ppm) as f32 - x0 as f32, ((r[1] - pad) * ppm) as f32 - y0 as f32);- if let Some(rr) = tiny_skia::Rect::from_xywh(rx, ry, ((r[2] - r[0] + 2.0 * pad) * ppm) as f32, ((r[3] - r[1] + 2.0 * pad) * ppm) as f32) {- let path = tiny_skia::PathBuilder::from_rect(rr);- crop.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);- }- }- let r2 = |v: f64| (v * 100.0).round() / 100.0;- Ok((crop, [r2(box_mm[0]), r2(box_mm[1]), r2(box_mm[2]), r2(box_mm[3])]))-}--pub struct Diff {- pub changed: u64,- pub total: u64,- pub percent: f64,- /// (x0, y0, x1, y1) inclusive, pixels.- pub bbox: Option<(u32, u32, u32, u32)>,- /// The current image with changed pixels painted red and the rest faded.- pub image: Pixmap,-}--/// Compare two same-size images: a pixel changed when any channel moved more than `tol`.-pub fn diff(base: &Pixmap, cur: &Pixmap, tol: u8) -> Result<Diff, String> {- if base.width() != cur.width() || base.height() != cur.height() {- return Err(format!("sizes differ: baseline {}x{}, now {}x{}", base.width(), base.height(), cur.width(), cur.height()));- }- let (w, h) = (cur.width(), cur.height());- let mut out = Pixmap::new(w, h).ok_or("cannot allocate the diff image")?;- let (a, b) = (base.data(), cur.data());- let o = out.data_mut();- let mut changed = 0u64;- let mut bb: Option<(u32, u32, u32, u32)> = None;- for i in 0..(w as usize * h as usize) {- let k = i * 4;- let moved = (0..3).any(|c| a[k + c].abs_diff(b[k + c]) > tol);- if moved {- changed += 1;- let (x, y) = ((i % w as usize) as u32, (i / w as usize) as u32);- bb = Some(match bb {- None => (x, y, x, y),- Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),- });- o[k] = 230;- o[k + 1] = 0;- o[k + 2] = 0;- o[k + 3] = 255;- } else {- // faded copy of the current image for context- for c in 0..3 {- o[k + c] = 255 - (255 - b[k + c]) / 4;- }- o[k + 3] = 255;- }- }- let total = w as u64 * h as u64;- Ok(Diff { changed, total, percent: if total == 0 { 0.0 } else { changed as f64 * 100.0 / total as f64 }, bbox: bb, image: out })-}--/// FNV-1a over the bytes of every file, in order: the baseline's source hash.-pub fn source_hash(files: &[PathBuf]) -> String {- let mut h: u64 = 0xcbf29ce484222325;- for f in files {- for b in std::fs::read(f).unwrap_or_default() {- h ^= b as u64;- h = h.wrapping_mul(0x100000001b3);- }- }- format!("{h:016x}")-}--/// A file-name-safe key for a sheet: `hier`, `hier-Power-Filter`.-pub fn baseline_key(root_stem: &str, human: &str) -> String {- let p = human.trim_matches('/');- let raw = if p.is_empty() { root_stem.to_string() } else { format!("{root_stem}-{}", p.replace('/', "-")) };- raw.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect()-}--pub fn baseline_dir(project_dir: &Path) -> PathBuf {- project_dir.join(".adom").join("baselines")-}--/// Write a PNG atomically.-pub fn save_png(p: &Pixmap, path: &Path) -> Result<(), String> {- let bytes = p.encode_png().map_err(|e| format!("cannot encode PNG: {e}"))?;- if let Some(d) = path.parent() {- std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;- } crate::libraries::write_atomic(path, &bytes) } -/// Write top-down RGB8 pixels as a PNG, scaled down (nearest pixel) so the width is at most-/// `max_width`. Returns the written (width, height).-pub fn save_rgb_png(width: u32, height: u32, rgb: &[u8], max_width: Option<u32>, path: &Path) -> Result<(u32, u32), String> {- if width == 0 || height == 0 || rgb.len() < (width * height * 3) as usize {- return Err(format!("bad image: {width}x{height} with {} bytes", rgb.len()));- }- let scale = max_width.filter(|m| *m > 0 && *m < width).map(|m| m as f64 / width as f64).unwrap_or(1.0);- let (w, h) = (((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1));- let mut pm = Pixmap::new(w, h).ok_or_else(|| format!("cannot allocate a {w}x{h} image"))?;- let data = pm.data_mut();- for y in 0..h {- let sy = ((y as f64 / scale) as u32).min(height - 1);- for x in 0..w {- let sx = ((x as f64 / scale) as u32).min(width - 1);- let si = ((sy * width + sx) * 3) as usize;- let di = ((y * w + x) * 4) as usize;- data[di..di + 4].copy_from_slice(&[rgb[si], rgb[si + 1], rgb[si + 2], 255]);- }- }- save_png(&pm, path)?;- Ok((w, h))-}- pub fn load_png(path: &Path) -> Result<Pixmap, String> { Pixmap::load_png(path).map_err(|e| format!("cannot read {}: {e}", path.display()))-}--/// Pixel box to sheet millimetres.-pub fn bbox_mm(b: (u32, u32, u32, u32), px_per_mm: f64) -> Value {- let f = |v: u32| ((v as f64 / px_per_mm) * 100.0).round() / 100.0;- json!([f(b.0), f(b.1), f(b.2 + 1), f(b.3 + 1)])-}--/// What `render_sheet` should do with the image.-#[derive(Clone, Copy, Debug, PartialEq, Eq)]-pub enum BaselineMode {- None,- Set,- Compare,-}--pub struct RenderJob<'a> {- /// The root sheet kicad-cli plots (sub-sheets are plotted from it).- pub root: &'a Path,- /// None for the root page, else (sheet name, human path `/Power/`).- pub sheet: Option<(&'a str, &'a str)>,- pub width: u32,- pub out_dir: &'a Path,- pub project_dir: &'a Path,- pub svg: SvgOpts,- pub mode: BaselineMode,- /// Allowed drift in percent of pixels for a compare to pass.- pub threshold: f64,- /// kicad-cli version (or path) recorded with a baseline.- pub renderer: String,- /// Schematic files whose bytes make the source hash.- pub sources: Vec<PathBuf>,-}--/// A failure with the reply's errorCode and hint.-#[derive(Debug)]-pub struct RenderError {- pub code: &'static str,- pub error: String,- pub hint: String,-}--fn rerr(code: &'static str, error: impl Into<String>, hint: impl Into<String>) -> RenderError {- RenderError { code, error: error.into(), hint: hint.into() }-}--/// Export, pick the sheet's SVG, rasterise to PNG, and set or compare the baseline.-pub fn render_sheet(run: Runner, job: &RenderJob) -> Result<Value, RenderError> {- let svg_dir = job.out_dir.join("svg");- let files = export_svgs(run, job.root, &svg_dir, &job.svg, Duration::from_secs(240)).map_err(|e| rerr("cli_failed", e, "kicad-cli could not plot the schematic: run kicad_run_erc or kicad_lint_schematic on the root sheet for the reason, and check the file opens in KiCad."))?;- let root_stem = stem(job.root);- let svg = pick_svg(&files, &root_stem, job.sheet).ok_or_else(|| {- rerr(- "sheet_not_found",- format!("no SVG for sheet {} among {}", job.sheet.map(|s| s.0).unwrap_or("root"), files.iter().map(|f| stem(f)).collect::<Vec<_>>().join(", ")),- "kicad_sch_hierarchy lists the sheet names; pass sheet as one of them, or omit it for the root page.",- )- })?;- let human = job.sheet.map(|s| s.1).unwrap_or("/");- let key = baseline_key(&root_stem, human);- let base_png = baseline_dir(job.project_dir).join(format!("{key}.png"));- let base_json = baseline_dir(job.project_dir).join(format!("{key}.json"));- let meta: Option<Value> = std::fs::read_to_string(&base_json).ok().and_then(|t| serde_json::from_str(&t).ok());- let width = if job.mode == BaselineMode::Compare { meta.as_ref().and_then(|m| m["width"].as_u64()).map(|w| w as u32).unwrap_or(job.width) } else { job.width };- let bytes = std::fs::read(&svg).map_err(|e| rerr("read_failed", format!("cannot read {}: {e}", svg.display()), "The export folder must be readable."))?;- let raster = rasterize(&bytes, width).map_err(|e| rerr("render_failed", e, "The SVG kicad-cli wrote could not be rasterised; the svg path in the reply is still usable."))?;- let png = job.out_dir.join(format!("{key}.png"));- save_png(&raster.pixmap, &png).map_err(|e| rerr("write_failed", e, "Pass an outputDir the desktop user can write."))?;- let hash = source_hash(&job.sources);- let px_per_mm = raster.pixmap.width() as f64 / raster.width_mm;- let mut v = json!({- "png": norm(&png),- "svg": norm(&svg),- "svgFiles": files.iter().map(|f| norm(f)).collect::<Vec<_>>(),- "sheet": job.sheet.map(|s| s.0).unwrap_or("Root"),- "sheetPath": human,- "width": raster.pixmap.width(),- "height": raster.pixmap.height(),- "pageMm": [((raster.width_mm * 100.0).round() / 100.0), ((raster.height_mm * 100.0).round() / 100.0)],- "sourceHash": hash,- });- match job.mode {- BaselineMode::None => {}- BaselineMode::Set => {- save_png(&raster.pixmap, &base_png).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;- let m = json!({"sheet": v["sheet"], "sheetPath": human, "width": raster.pixmap.width(), "height": raster.pixmap.height(), "sourceHash": hash, "renderer": job.renderer, "rasterizer": "resvg 0.45", "createdAt": now_iso()});- crate::libraries::write_atomic(&base_json, serde_json::to_string_pretty(&m).unwrap_or_default().as_bytes()).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;- v["baseline"] = json!({"set": true, "png": norm(&base_png), "meta": norm(&base_json), "replaced": meta.is_some()});- }- BaselineMode::Compare => {- let Some(meta) = meta else {- v["baseline"] = json!({"stored": false, "png": norm(&base_png)});- return Err(RenderError { code: "no_baseline", error: format!("no baseline stored for {key} (looked for {})", norm(&base_png)), hint: "Set one first: kicad_render_schematic {baseline:\"set\"} on the same sheet, then compare after the edit.".into() });- };- let base = load_png(&base_png).map_err(|e| rerr("no_baseline", e, "The baseline PNG is missing or damaged: set it again with baseline:\"set\"."))?;- let renderer_changed = meta["renderer"].as_str().map_or(false, |r| r != job.renderer);- let source_changed = meta["sourceHash"].as_str().map_or(true, |h| h != hash);- match diff(&base, &raster.pixmap, 24) {- Ok(d) => {- let diff_png = job.out_dir.join(format!("{key}-diff.png"));- let _ = save_png(&d.image, &diff_png);- let pass = d.percent <= job.threshold;- v["baseline"] = json!({- "stored": true,- "png": norm(&base_png),- "createdAt": meta["createdAt"],- "driftPercent": (d.percent * 1000.0).round() / 1000.0,- "changedPixels": d.changed,- "totalPixels": d.total,- "threshold": job.threshold,- "pass": pass,- "changedRegionPx": d.bbox.map(|b| json!([b.0, b.1, b.2, b.3])),- "changedRegionMm": d.bbox.map(|b| bbox_mm(b, px_per_mm)),- "diffPng": norm(&diff_png),- "sourceChanged": source_changed,- "rendererChanged": renderer_changed,- "baselineRenderer": meta["renderer"],- });- }- Err(e) => {- v["baseline"] = json!({"stored": true, "png": norm(&base_png), "sizeChanged": true, "detail": e, "driftPercent": 100.0, "pass": false, "threshold": job.threshold, "sourceChanged": source_changed, "rendererChanged": renderer_changed});- }- }- }- }- Ok(v)-}--fn now_iso() -> String {- let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);- let days = (secs / 86400) as i64;- let rem = secs % 86400;- // civil from days (Howard Hinnant)- let z = days + 719468;- let era = z.div_euclid(146097);- let doe = z.rem_euclid(146097);- let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;- let y = yoe + era * 400;- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);- let mp = (5 * doy + 2) / 153;- let d = doy - (153 * mp + 2) / 5 + 1;- let m = if mp < 10 { mp + 3 } else { mp - 9 };- let y = if m <= 2 { y + 1 } else { y };- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)-}--#[cfg(test)]-mod tests {- use super::*;- use crate::cli::CliResult;-- fn fixture_svg() -> Vec<u8> {- std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch_hier/mcu-kicad10.svg")).unwrap()- }-- #[test]- fn argv_is_kicad_cli_shape() {- let a = svg_argv(Path::new("C:\\p\\x.kicad_sch"), Path::new("C:/out"), &SvgOpts { drawing_sheet: false, black_and_white: true, theme: Some("KiCad Classic".into()) });- assert_eq!(a, vec!["sch", "export", "svg", "--output", "C:/out", "--exclude-drawing-sheet", "--black-and-white", "--theme", "KiCad Classic", "C:/p/x.kicad_sch"]);- assert_eq!(svg_argv(Path::new("/p/x.kicad_sch"), Path::new("/o"), &SvgOpts::default()).len(), 6);- }-- #[test]- fn pick_by_sheet() {- let files: Vec<PathBuf> = ["hier.svg", "hier-Power.svg", "hier-Power-Filter.svg", "hier-MCU.svg"].iter().map(PathBuf::from).collect();- assert_eq!(pick_svg(&files, "hier", None).unwrap(), PathBuf::from("hier.svg"));- assert_eq!(pick_svg(&files, "hier", Some(("Filter", "/Power/Filter/"))).unwrap(), PathBuf::from("hier-Power-Filter.svg"));- assert_eq!(pick_svg(&files, "hier", Some(("Power", "/Power/"))).unwrap(), PathBuf::from("hier-Power.svg"));- assert_eq!(pick_svg(&files, "hier", Some(("mcu", "/x/"))).unwrap(), PathBuf::from("hier-MCU.svg"));- assert!(pick_svg(&files, "hier", Some(("Nope", "/Nope/"))).is_none());- }-- #[test]- fn export_through_a_canned_runner() {- let dir = std::env::temp_dir().join(format!("sch-render-{}-{}", std::process::id(), crate::schematic::new_uuid()));- let svg = fixture_svg();- let seen = std::sync::Mutex::new(Vec::<String>::new());- let run = |argv: &[String], _t: Duration| -> CliResult {- *seen.lock().unwrap() = argv.to_vec();- let out = PathBuf::from(&argv[4]);- std::fs::write(out.join("hier.svg"), &svg).unwrap();- std::fs::write(out.join("hier-Power.svg"), &svg).unwrap();- CliResult { exit_code: 0, stdout: "Plotted to 'hier.svg'.\nDone.\n".into(), stderr: String::new(), elapsed_ms: 5, timed_out: false }- };- let files = export_svgs(&run, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap();- assert_eq!(files.len(), 2);- assert_eq!(seen.lock().unwrap()[..3], ["sch", "export", "svg"]);- // a failing kicad-cli: no SVG, stderr surfaces- let bad = |_: &[String], _: Duration| CliResult { exit_code: 3, stdout: String::new(), stderr: "Failed to load schematic".into(), elapsed_ms: 1, timed_out: false };- let e = export_svgs(&bad, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap_err();- assert!(e.contains("exit 3") && e.contains("Failed to load"), "{e}");- let _ = std::fs::remove_dir_all(&dir);- }-- #[test]- fn rasterize_and_diff_a_kicad_svg() {- let svg = fixture_svg();- let r = rasterize(&svg, 1200).unwrap();- assert_eq!(r.pixmap.width(), 1200);- assert!((r.width_mm - 297.0).abs() < 0.1, "{}", r.width_mm);- let h = r.pixmap.height();- assert!((h as f64 - 1200.0 * 210.0 / 297.0).abs() < 2.0);- // something got drawn: not all white- if let Ok(dir) = std::env::var("SCH_RENDER_DUMP") {- save_png(&r.pixmap, &Path::new(&dir).join("mcu.png")).unwrap();- }- let dark = r.pixmap.data().chunks(4).filter(|p| p[0].min(p[1]).min(p[2]) < 180).count();- assert!(dark > 1000, "only {dark} dark pixels");- let same = diff(&r.pixmap, &r.pixmap, 16).unwrap();- assert_eq!(same.changed, 0);- assert!(same.bbox.is_none());- // move a wire: the drift lands where the wire is- let text = String::from_utf8(svg.clone()).unwrap();- let edited = text.replacen("</svg>", "<rect x=\"100\" y=\"100\" width=\"20\" height=\"10\" fill=\"#000000\"/></svg>", 1);- let r2 = rasterize(edited.as_bytes(), 1200).unwrap();- let d = diff(&r.pixmap, &r2.pixmap, 16).unwrap();- assert!(d.percent > 0.25 && d.percent < 0.5, "{}", d.percent); // 20 x 10 mm of 297 x 210- let (x0, y0, x1, y1) = d.bbox.unwrap();- let ppm = 1200.0 / r.width_mm;- let mm = bbox_mm((x0, y0, x1, y1), ppm);- assert!((mm[0].as_f64().unwrap() - 100.0).abs() < 0.5 && (mm[2].as_f64().unwrap() - 120.0).abs() < 0.5, "{mm}");- assert!(rasterize(b"not svg", 100).is_err());- assert!(diff(&r.pixmap, &rasterize(&svg, 600).unwrap().pixmap, 16).is_err());- }-- #[test]- fn baseline_set_then_compare() {- let dir = std::env::temp_dir().join(format!("sch-baseline-{}-{}", std::process::id(), crate::schematic::new_uuid()));- std::fs::create_dir_all(&dir).unwrap();- let root = dir.join("mcu.kicad_sch");- std::fs::write(&root, "(kicad_sch (version 20260306))").unwrap();- let svg = std::sync::Mutex::new(fixture_svg());- let run = |argv: &[String], _t: Duration| -> CliResult {- std::fs::write(PathBuf::from(&argv[4]).join("mcu.svg"), &*svg.lock().unwrap()).unwrap();- CliResult { exit_code: 0, stdout: String::new(), stderr: String::new(), elapsed_ms: 1, timed_out: false }- };- let out = dir.join("render");- let mut job = RenderJob { root: &root, sheet: None, width: 600, out_dir: &out, project_dir: &dir, svg: SvgOpts::default(), mode: BaselineMode::Compare, threshold: 0.1, renderer: "kicad-cli 10.0.2".into(), sources: vec![root.clone()] };- let e = render_sheet(&run, &job).unwrap_err();- assert_eq!(e.code, "no_baseline");- job.mode = BaselineMode::Set;- let v = render_sheet(&run, &job).unwrap();- assert_eq!(v["baseline"]["set"], true);- assert!(dir.join(".adom/baselines/mcu.png").is_file() && dir.join(".adom/baselines/mcu.json").is_file());- // unchanged: zero drift, compare renders at the baseline's width even when asked otherwise- job.mode = BaselineMode::Compare;- job.width = 900;- let v = render_sheet(&run, &job).unwrap();- assert_eq!(v["width"], 600);- assert_eq!(v["baseline"]["driftPercent"], 0.0);- assert_eq!(v["baseline"]["pass"], true);- assert_eq!(v["baseline"]["sourceChanged"], false);- // an edit: a block appears at x 200..220 mm- let edited = String::from_utf8(fixture_svg()).unwrap().replacen("</svg>", "<rect x=\"200\" y=\"150\" width=\"20\" height=\"20\" fill=\"#000000\"/></svg>", 1);- *svg.lock().unwrap() = edited.into_bytes();- std::fs::write(&root, "(kicad_sch (version 20260306) (uuid \"x\"))").unwrap();- let v = render_sheet(&run, &job).unwrap();- let b = &v["baseline"];- assert_eq!(b["pass"], false, "{b}");- assert_eq!(b["sourceChanged"], true);- let mm = b["changedRegionMm"].as_array().unwrap();- assert!((mm[0].as_f64().unwrap() - 200.0).abs() < 1.0 && (mm[3].as_f64().unwrap() - 170.0).abs() < 1.0, "{mm:?}");- assert!(PathBuf::from(b["diffPng"].as_str().unwrap()).is_file());- // another renderer is flagged, never silently trusted- job.renderer = "kicad-cli 10.0.3".into();- assert_eq!(render_sheet(&run, &job).unwrap()["baseline"]["rendererChanged"], true);- let _ = std::fs::remove_dir_all(&dir);- }-- #[test]- fn iso_dates_look_right() {- let s = now_iso();- assert_eq!(s.len(), 20);- assert!(s.starts_with("20") && s.ends_with('Z'));- }-- #[test]- fn keys_are_file_safe() {- assert_eq!(baseline_key("hier", "/"), "hier");- assert_eq!(baseline_key("hier", "/Power/Filter/"), "hier-Power-Filter");- assert_eq!(baseline_key("hier", "/A B:C/"), "hier-A_B_C");- }-- #[test]- fn a_frame_crops_the_page_to_the_parts_and_outlines_them() {- // an A4-sized page (297 x 210 mm) with one black box at 100..110, 50..60 mm- let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="210mm" viewBox="0 0 297 210"><rect x="100" y="50" width="10" height="10" fill="black"/></svg>"#;- let (img, region) = super::frame(svg, &[[100.0, 50.0, 110.0, 60.0]], 10.0, 600).unwrap();- assert_eq!(region, [90.0, 40.0, 120.0, 70.0]);- assert!((img.width() as i32 - 600).abs() <= 2, "{}", img.width());- // the box sits in the middle of the frame, and the outline is red- let mid = img.pixel(img.width() / 2, img.height() / 2).unwrap();- assert!(mid.red() < 40 && mid.green() < 40);- let reds = img.pixels().iter().filter(|p| p.red() > 180 && p.green() < 80).count();- assert!(reds > 100, "{reds}");- assert!(super::frame(svg, &[], 5.0, 600).is_err());- }-}+}
rust/crates/kicad-core/src/spice.rs+2−651@@ -1,662 +1,13 @@-//! SPICE for kicad_export_spice and kicad_simulate (wiki #89), the pure half: the netlist KiCad exports-//! (`kicad-cli sch export netlist --format spice`) made ready to run with the analysis asked for, and-//! ngspice's vectors summarised for an AI (each probe downsampled, with its first, last, min and max; AC as-//! magnitude in dB and phase in degrees). The run itself is kicad_platform::ngspice, in a child process.--use serde_json::{json, Value};--/// The dot-commands that start an analysis.-const ANALYSES: &[&str] = &[".tran", ".ac", ".dc", ".op", ".noise", ".tf", ".disto", ".pz", ".sp"];--fn is_analysis(line: &str) -> bool {- let l = line.trim_start().to_lowercase();- ANALYSES.iter().any(|a| l == *a || l.starts_with(&format!("{a} ")))-}--/// What the exported netlist holds: its title, the analyses the schematic carries, its .include/.lib lines-/// and how many element lines.-pub fn describe(netlist: &str) -> Value {- let lines: Vec<&str> = netlist.lines().collect();- let title = lines.iter().find_map(|l| l.trim().strip_prefix(".title").map(|t| t.trim().to_string()));- let analyses: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| is_analysis(l)).collect();- let includes: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| { let x = l.to_lowercase(); x.starts_with(".include") || x.starts_with(".lib") }).collect();- let elements = lines.iter().filter(|l| { let t = l.trim_start(); !t.is_empty() && !t.starts_with('.') && !t.starts_with('*') && !t.starts_with('+') }).count();- json!({"title": title, "analyses": analyses, "includes": includes, "elements": elements, "lines": lines.len()})-}--/// A number given as JSON or as SPICE text ("100p", "4k7" is not SPICE: "4.7k", "1meg").-pub fn spice_num(v: &Value) -> Option<f64> {- if let Some(f) = v.as_f64() { return Some(f); }- let t = v.as_str()?.trim().to_ascii_lowercase();- let split = t.find(|c: char| c.is_ascii_alphabetic() && c != 'e').unwrap_or(t.len());- // "1meg" / "1e-9": the mantissa stops at the first letter that is not an exponent- let (num, suf) = match t[..split].parse::<f64>() {- Ok(n) => (n, &t[split..]),- Err(_) => return None,- };- let m = if suf.starts_with("meg") { 1e6 } else {- match suf.chars().next() { None => 1.0, Some('t') => 1e12, Some('g') => 1e9, Some('k') => 1e3, Some('m') => 1e-3, Some('u') => 1e-6, Some('n') => 1e-9, Some('p') => 1e-12, Some('f') => 1e-15, _ => return None }- };- Some(num * m)-}--/// A value the way a person reads it: 300, 4.7k, 100p, 1meg.-pub fn spice_label(v: f64) -> String {- let a = v.abs();- let (d, s) = if a == 0.0 { (1.0, "") } else if a >= 1e12 { (1e12, "T") } else if a >= 1e9 { (1e9, "G") } else if a >= 1e6 { (1e6, "meg") } else if a >= 1e3 { (1e3, "k") } else if a >= 1.0 { (1.0, "") }- else if a >= 1e-3 { (1e-3, "m") } else if a >= 1e-6 { (1e-6, "u") } else if a >= 1e-9 { (1e-9, "n") } else if a >= 1e-12 { (1e-12, "p") } else { (1e-15, "f") };- let x = ((v / d) * 1e4).round() / 1e4;- format!("{x}{s}")-}--/// The values of a sweep {start, stop, step, scale?}: lin steps by `step`; dec and oct take `step` points per-/// decade or octave. At most `cap` values.-pub fn sweep_values(start: f64, stop: f64, step: f64, scale: &str, cap: usize) -> Result<Vec<f64>, String> {- let mut out = Vec::new();- match scale {- "lin" => {- if step == 0.0 || (stop - start) * step < 0.0 { return Err("step must be non-zero and run from start toward stop".into()); }- let n = ((stop - start) / step + 1e-9).floor() as i64;- if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }- for k in 0..=n { out.push(start + k as f64 * step); }- }- "dec" | "oct" => {- if start <= 0.0 || stop < start || step < 1.0 { return Err(format!("{scale} needs 0 < start <= stop and step >= 1 point per {}", if scale == "dec" { "decade" } else { "octave" })); }- let base: f64 = if scale == "dec" { 10.0 } else { 2.0 };- let n = ((stop / start).log(base) * step + 1e-9).floor() as i64;- if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }- for k in 0..=n { out.push(start * base.powf(k as f64 / step)); }- }- o => return Err(format!("scale is lin, dec or oct, not {o}")),- }- Ok(out)-}--/// The element KiCad named for a designator: R4 itself, or with its SPICE letter in front (XU1).-fn element<'a>(netlist: &'a str, reference: &str) -> Option<&'a str> {- let r = reference.to_ascii_uppercase();- netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])).find(|t| {- let n = t.split_whitespace().next().unwrap_or("").to_ascii_uppercase();- n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str()))- })-}--/// The ngspice command that sets one sweep value: a part's value ("R4") with alter, a model parameter ("Q1.BF")-/// with altermod on the model the part uses. `models` are the .model names the netlist and its includes define.-pub fn sweep_command(netlist: &str, parameter: &str, value: f64, models: &[String]) -> Result<String, String> {- let (part, param) = match parameter.split_once('.') { Some((p, m)) => (p, Some(m)), None => (parameter, None) };- let line = element(netlist, part).ok_or_else(|| format!("{part} is not in the netlist (a part without a model, or no such designator)"))?;- let name = line.split_whitespace().next().unwrap_or(part);- match param {- None => Ok(format!("alter {name} = {value:e}")),- Some(p) => {- let model = line.split_whitespace().skip(1).find(|t| models.iter().any(|m| m.eq_ignore_ascii_case(t)))- .ok_or_else(|| format!("{part}'s model is not one the netlist defines (.model), so {p} cannot be stepped"))?;- Ok(format!("altermod {model} {p} = {value:e}"))- }- }-}--/// The .model names in netlist text.-pub fn model_names(text: &str) -> Vec<String> {- text.lines().filter_map(|l| { let t: Vec<&str> = l.split_whitespace().collect(); (t.len() >= 2 && t[0].eq_ignore_ascii_case(".model")).then(|| t[1].to_string()) }).collect()-}--/// The netlist with `.temp t` before its .end.-pub fn with_temp(lines: &[String], t: f64) -> Vec<String> {- let mut out: Vec<String> = lines.iter().filter(|l| !l.trim().to_ascii_lowercase().starts_with(".temp")).cloned().collect();- let at = out.iter().rposition(|l| l.trim().eq_ignore_ascii_case(".end")).unwrap_or(out.len());- out.insert(at, format!(".temp {t}"));- out-}--/// A noise run's integrated totals {onoise, inoise} in V (null when the run has none).-pub fn noise_totals(run: &Value) -> Value {- let get = |k: &str| run["vectors"].as_array().into_iter().flatten().find(|v| v["name"].as_str().map(|n| n.eq_ignore_ascii_case(k)).unwrap_or(false))- .and_then(|v| v["real"].as_array().and_then(|a| a.first()).and_then(Value::as_f64));- match (get("onoise_total"), get("inoise_total")) {- (None, None) => Value::Null,- (o, i) => json!({"onoise": o, "inoise": i}),- }-}--/// A net as the netlist names its node: KiCad writes a labelled net on the root sheet as /OUT, ground as GND.-pub fn resolve_node(netlist: &str, net: &str) -> String {- let nodes: std::collections::BTreeSet<&str> = netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))- .flat_map(|t| t.split_whitespace().skip(1)).collect();- let n = net.trim();- if nodes.contains(n) { return n.to_string(); }- let slash = format!("/{}", n.trim_start_matches('/'));- if nodes.contains(slash.as_str()) { return slash; }- nodes.iter().find(|x| x.eq_ignore_ascii_case(n) || x.eq_ignore_ascii_case(&slash)).map(|x| x.to_string()).unwrap_or_else(|| n.to_string())-}--/// The parts a Monte Carlo run can draw, from the netlist: resistors, capacitors and inductors by their value, DC-/// sources by their DC value. (element name, kind, nominal)-pub fn mc_parts(netlist: &str) -> Vec<(String, &'static str, f64)> {- let mut out = Vec::new();- for l in netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])) {- let t: Vec<&str> = l.split_whitespace().collect();- let Some(first) = t.first().and_then(|n| n.chars().next()) else { continue };- let kind = match first.to_ascii_uppercase() { 'R' => "resistor", 'C' => "capacitor", 'L' => "inductor", 'V' => "dcSource", _ => continue };- let value = if kind == "dcSource" {- t.iter().position(|x| x.eq_ignore_ascii_case("dc")).and_then(|i| t.get(i + 1)).or_else(|| t.get(3)).and_then(|v| spice_num(&json!(v)))- } else {- t.get(3).and_then(|v| spice_num(&json!(v)))- };- if let Some(v) = value.filter(|v| *v != 0.0) {- out.push((t[0].to_string(), kind, v));- }- }- out-}--/// splitmix64: the same seed gives the same draws on every machine.-struct Rng(u64);-impl Rng {- fn next(&mut self) -> f64 {- self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);- let mut z = self.0;- z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);- z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);- ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64- }-}--/// A value to six significant digits, the way a run's step lists what was drawn.-pub fn sig6(v: f64) -> String {- if v == 0.0 { return "0".into(); }- let e = v.abs().log10().floor() as i32;- if (-3..6).contains(&e) { let d = (5 - e).max(0) as usize; let s = format!("{v:.d$}"); if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } else { format!("{v:.5e}") }-}--/// Each run's drawn values for the parts whose kind has a tolerance (%): uniform within +/-tol, or gaussian with-/// sigma tol/3 clipped to +/-tol.-pub fn mc_draws(parts: &[(String, &'static str, f64)], tolerances: &Value, runs: usize, gaussian: bool, seed: u64) -> Vec<Vec<(String, f64)>> {- let mut rng = Rng(seed);- (0..runs).map(|_| parts.iter().filter_map(|(name, kind, nominal)| {- let tol = tolerances[*kind].as_f64().filter(|t| *t > 0.0)? / 100.0;- let d = if gaussian {- let (u1, u2) = (rng.next().max(1e-12), rng.next());- ((-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() * tol / 3.0).clamp(-tol, tol)- } else {- (rng.next() * 2.0 - 1.0) * tol- };- Some((name.clone(), nominal * (1.0 + d)))- }).collect()).collect()-}--/// KiCad writes a part it has no model for as a stub with no nodes (`J1 __J1`), which ngspice cannot parse: the-/// netlist without those lines, and the stubs' element names.-pub fn strip_unmodelled(netlist: &str) -> (String, Vec<String>) {- let mut dropped = Vec::new();- let kept: Vec<&str> = netlist.lines().filter(|l| {- let t: Vec<&str> = l.split_whitespace().collect();- let stub = t.len() == 2 && !t[0].starts_with(['.', '*', '+']) && t[1].starts_with("__");- if stub { dropped.push(t[0].to_string()); }- !stub- }).collect();- (kept.join("\n") + "\n", dropped)-}--/// The parts on the sheet that did not make it into KiCad's netlist (`refs` are the sheet's designators): KiCad-/// names each element by its designator, with the SPICE letter put in front when the designator does not start-/// with it (U1 as a subcircuit is XU1), so a designator no element ends with was left out for want of a model.-pub fn not_simulated(netlist: &str, refs: &[String]) -> Vec<String> {- let names: Vec<String> = netlist.lines().map(str::trim_start)- .filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))- .filter_map(|t| t.split_whitespace().next()).map(str::to_ascii_uppercase).collect();- refs.iter().filter(|r| {- let r = r.to_ascii_uppercase();- !names.iter().any(|n| *n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str())))- }).cloned().collect()-}--/// The circuit to run: the netlist without its `.end`, the schematic's own analyses commented out when-/// `analyses` are given (theirs win), then those analyses and `.end`.-pub fn prepare(netlist: &str, analyses: &[String]) -> Result<Vec<String>, String> {- let mut out: Vec<String> = Vec::new();- for l in netlist.lines() {- let t = l.trim();- if t.eq_ignore_ascii_case(".end") {- continue;- }- if !analyses.is_empty() && is_analysis(t) {- out.push(format!("* replaced by the call: {t}"));- continue;- }- out.push(l.to_string());- }- for a in analyses {- let a = a.trim();- if !is_analysis(a) {- return Err(format!("{a:?} is not an analysis: give one of {}", ANALYSES.join(", ")));- }- out.push(a.to_string());- }- if analyses.is_empty() && !out.iter().any(|l| is_analysis(l)) {- return Err("the schematic carries no analysis and none was given: pass analysis, e.g. \".tran 10u 5m\" or \".ac dec 20 10 1meg\"".into());- }- out.push(".end".into());- Ok(out)-}--/// A probe's name as ngspice names its vector: `V(/OUT)` and `/out` both mean the node vector `/out`;-/// `I(R1)` is `r1#branch`.-pub fn vector_name(probe: &str) -> String {- let p = probe.trim().to_lowercase();- if let Some(inner) = p.strip_prefix("v(").and_then(|s| s.strip_suffix(')')) {- return inner.to_string();- }- if let Some(inner) = p.strip_prefix("i(").and_then(|s| s.strip_suffix(')')) {- return format!("{inner}#branch");- }- p-}--fn r(v: f64) -> f64 {- if v == 0.0 || !v.is_finite() {- return v;- }- let mag = 10f64.powi(5 - v.abs().log10().ceil() as i32);- (v * mag).round() / mag-}--fn pick(data: &[f64], idx: &[usize]) -> Vec<f64> {- idx.iter().map(|&i| r(data[i])).collect()-}--/// The run's vectors as the reply gives them: the sweep (time, frequency or a DC sweep) and each probe-/// (every node and branch vector when none are named, at most 24), downsampled to `points`.-pub fn summarize(run: &Value, probes: &[String], points: usize) -> Result<Value, String> {- let vecs = run["vectors"].as_array().cloned().unwrap_or_default();- let name = |v: &Value| v["name"].as_str().unwrap_or("").to_lowercase();- let sweep = vecs.iter().find(|v| ["time", "frequency", "v-sweep", "i-sweep", "temp-sweep"].contains(&name(v).as_str()) || name(v).ends_with("-sweep"));- let len = vecs.iter().map(|v| v["real"].as_array().or(v["re"].as_array()).map(|a| a.len()).unwrap_or(0)).max().unwrap_or(0);- let points = points.clamp(2, 5000);- let idx: Vec<usize> = if len <= points { (0..len).collect() } else { (0..points).map(|k| k * (len - 1) / (points - 1)).collect() };- let nums = |v: &Value, k: &str| -> Vec<f64> { v[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default() };- let wanted: Vec<String> = probes.iter().map(|p| vector_name(p)).collect();- let mut missing = Vec::new();- let chosen: Vec<&Value> = if wanted.is_empty() {- vecs.iter().filter(|v| Some(*v) != sweep).take(24).collect()- } else {- let mut c = Vec::new();- for w in &wanted {- match vecs.iter().find(|v| name(v) == *w || name(v).trim_start_matches('/') == w.trim_start_matches('/')) {- Some(v) => c.push(v),- None => missing.push(w.clone()),- }- }- c- };- if chosen.is_empty() {- return Err(format!("none of the probes is a vector of this run ({}); it has: {}", wanted.join(", "), vecs.iter().map(name).take(40).collect::<Vec<_>>().join(", ")));- }- let mut out = Vec::new();- for v in chosen {- let n = v["name"].as_str().unwrap_or("");- if v["real"].is_array() {- let d = nums(v, "real");- if d.is_empty() {- continue;- }- let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));- out.push(json!({"name": n, "first": r(d[0]), "last": r(*d.last().unwrap()), "min": r(lo), "max": r(hi), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())}));- } else {- let (re, im) = (nums(v, "re"), nums(v, "im"));- let db: Vec<f64> = re.iter().zip(&im).map(|(a, b)| 20.0 * (a.hypot(*b)).log10()).collect();- let ph: Vec<f64> = re.iter().zip(&im).map(|(a, b)| b.atan2(*a).to_degrees()).collect();- if db.is_empty() {- continue;- }- let ii: Vec<usize> = idx.iter().copied().filter(|&i| i < db.len()).collect();- let (lo, hi) = db.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));- out.push(json!({"name": n, "complex": true, "firstDb": r(db[0]), "lastDb": r(*db.last().unwrap()), "minDb": r(lo), "maxDb": r(hi), "db": pick(&db, &ii), "phaseDeg": pick(&ph, &ii)}));- }- }- let x = sweep.map(|s| {- let d = if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") };- json!({"name": s["name"], "first": d.first().map(|v| r(*v)), "last": d.last().map(|v| r(*v)), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())})- });- Ok(json!({"plot": run["plot"], "points": idx.len(), "of": len, "sweep": x, "probes": out, "missing": missing,- "available": vecs.iter().map(name).take(60).collect::<Vec<_>>()}))-}--/// One analysis in the standard's form (#23) as SPICE text: `{type: op}`, `{type: dc, source, start, stop, step}`,-/// `{type: ac, sweep: dec|oct|lin, points, start, stop}`, `{type: tran, step, stop, start?}` or `{type: raw, text}`;-/// a string is taken as SPICE text.-pub fn analysis_text(a: &Value) -> Result<String, String> {- if let Some(t) = a.as_str() {- return Ok(t.trim().to_string());- }- let num = |k: &str| a.get(k).and_then(Value::as_f64).ok_or(format!("analysis {} needs {k}", a["type"].as_str().unwrap_or("?")));- let g = |v: f64| format!("{v}");- match a["type"].as_str().unwrap_or("") {- "op" => Ok(".op".into()),- "raw" => a["text"].as_str().map(|t| t.trim().to_string()).ok_or("a raw analysis carries its SPICE text in text".into()),- "dc" => Ok(format!(".dc {} {} {} {}", a["source"].as_str().ok_or("a dc analysis names its source")?, g(num("start")?), g(num("stop")?), g(num("step")?))),- "ac" => {- let sweep = a["sweep"].as_str().unwrap_or("dec"); if !["dec", "oct", "lin"].contains(&sweep) { return Err("an ac sweep is dec, oct or lin".into()); }- let (start, stop) = (num("start")?, num("stop")?);- // One frequency: a decade or octave sweep from f to f gives no points on some ngspice- // versions (42, the Ubuntu 24.04 library); one linear point at f is the same answer everywhere.- if start == stop && sweep != "lin" {- return Ok(format!(".ac lin 1 {} {}", g(start), g(stop)));- }- Ok(format!(".ac {sweep} {} {} {}", num("points")? as u64, g(start), g(stop)))+ Ok(format!(".ac {sweep} {} {} {}", num("points")? as u64, g(num("start")?), g(num("stop")?))) } "tran" => Ok(match a.get("start").and_then(Value::as_f64) { Some(st) => format!(".tran {} {} {}", g(num("step")?), g(num("stop")?), g(st)),- None => format!(".tran {} {}", g(num("step")?), g(num("stop")?)),- }),- // output and reference are node names as the netlist writes them (resolve_node)- "noise" => {- let sweep = a["sweep"].as_str().unwrap_or("dec");- if !["dec", "oct", "lin"].contains(&sweep) {- return Err("a noise sweep is dec, oct or lin".into());- }- let out = a["output"].as_str().ok_or("a noise analysis names its output net")?;- let node = match a["reference"].as_str().filter(|r| !r.is_empty() && *r != "0") { Some(r) => format!("v({out},{r})"), None => format!("v({out})") };- let points = a.get("points").and_then(Value::as_f64).unwrap_or(10.0) as u64;- Ok(format!(".noise {node} {} {sweep} {points} {} {}", a["source"].as_str().ok_or("a noise analysis names its input source")?, g(num("start")?), g(num("stop")?)))- }- t => Err(format!("analysis type {t:?} is not op, dc, ac, tran, noise or raw")),- }-}--/// A probe in the standard's form, `{net: "OUT"}` or `{current: "V1"}`, or a string (`V(/OUT)`, `I(V1)`), as-/// the vector name ngspice gives it.-pub fn probe_vector(p: &Value) -> Option<String> {- if let Some(s) = p.as_str() {- return Some(vector_name(s));- }- if let Some(n) = p["net"].as_str() {- return Some(n.trim().to_lowercase());- }- p["current"].as_str().map(|c| format!("{}#branch", c.trim().to_lowercase()))-}--/// Indices to keep from `y` within `points` (a ceiling): the first and last points and, per bucket, the lowest-/// and highest, so no peak is decimated away (#23).-pub fn keep_extrema(y: &[f64], points: usize) -> Vec<usize> {- let n = y.len();- if n <= points.max(2) {- return (0..n).collect();- }- let buckets = ((points.max(4) - 2) / 2).max(1);- let mut idx = vec![0, n - 1];- let inner = n - 2;- for b in 0..buckets {- let (lo, hi) = (1 + b * inner / buckets, 1 + (b + 1) * inner / buckets);- if lo >= hi {- continue;- }- let (mut imin, mut imax) = (lo, lo);- for i in lo..hi {- if y[i] < y[imin] {- imin = i;- }- if y[i] > y[imax] {- imax = i;- }- }- idx.push(imin);- idx.push(imax);- }- idx.sort_unstable();- idx.dedup();- idx-}--/// Phase in degrees with its 360-degree jumps removed.-pub fn unwrap_deg(p: &[f64]) -> Vec<f64> {- let mut out: Vec<f64> = Vec::with_capacity(p.len());- let mut offset = 0.0f64;- for (i, v) in p.iter().enumerate() {- if i > 0 {- let d = v + offset - out[i - 1];- if d > 180.0 {- offset -= 360.0 * ((d + 180.0) / 360.0).floor();- } else if d < -180.0 {- offset += 360.0 * ((-d + 180.0) / 360.0).floor();- }- }- out.push(v + offset);- }- out-}--/// The run's signals in the standard's shape (#23): `[{signal, x, y, xUnit, yUnit, phase?}]`, each decimated on its-/// own within `points`, keeping its first, last and extreme points; AC as dB with unwrapped phase in degrees.-/// `wanted` are vector names (probe_vector); empty means every node and branch (24 at most).-pub fn signals(run: &Value, wanted: &[String], points: usize) -> (Vec<Value>, Vec<String>) {- let vecs = run["vectors"].as_array().cloned().unwrap_or_default();- let name = |v: &Value| v["name"].as_str().unwrap_or("").to_lowercase();- let is_sweep = |n: &str| ["time", "frequency"].contains(&n) || n.ends_with("-sweep");- let sweep = vecs.iter().find(|v| is_sweep(&name(v)));- let sn = sweep.map(name).unwrap_or_default();- let x_unit = match sn.as_str() { "time" => "s", "frequency" => "Hz", "i-sweep" => "A", "temp-sweep" => "C", "" => "", _ => "V" };- let nums = |v: &Value, k: &str| -> Vec<f64> { v[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default() };- let x_all: Vec<f64> = sweep.map(|s| if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") }).unwrap_or_default();- let mut missing = Vec::new();- // a noise run's integrated totals are single values, answered as totals (noise_totals), not as signals- let vecs: Vec<Value> = vecs.into_iter().filter(|v| !v["name"].as_str().unwrap_or("").to_lowercase().ends_with("noise_total")).collect();- let chosen: Vec<&Value> = if wanted.is_empty() {- vecs.iter().filter(|v| !is_sweep(&name(v))).take(24).collect()- } else {- let mut c = Vec::new();- for w in wanted {- match vecs.iter().find(|v| name(v) == *w || name(v).trim_start_matches('/') == w.trim_start_matches('/')) {- Some(v) => c.push(v),- None => missing.push(w.clone()),- }- }- c- };- let label = |n: &str| if let Some(k) = n.strip_suffix("_spectrum") { k.to_string() } else if n.ends_with("#branch") { format!("I({})", n.trim_end_matches("#branch").to_uppercase()) } else { format!("V({})", n.trim_start_matches('/').to_uppercase()) };- let mut out = Vec::new();- for v in chosen {- let n = name(v);- let current = n.ends_with("#branch");- if v["real"].is_array() {- let y = nums(v, "real");- let idx = keep_extrema(&y, points);- let x: Vec<f64> = if x_all.len() == y.len() { idx.iter().map(|&i| r(x_all[i])).collect() } else { idx.iter().map(|&i| i as f64).collect() };- let y_unit = if n.ends_with("noise_spectrum") { "V/rtHz" } else if current { "A" } else { "V" };- out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(y[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": y_unit}));- } else {- let (re, im) = (nums(v, "re"), nums(v, "im"));- let db: Vec<f64> = re.iter().zip(&im).map(|(a, b)| 20.0 * a.hypot(*b).log10()).collect();- let ph = unwrap_deg(&re.iter().zip(&im).map(|(a, b)| b.atan2(*a).to_degrees()).collect::<Vec<_>>());- let idx = keep_extrema(&db, points);- let x: Vec<f64> = if x_all.len() == db.len() { idx.iter().map(|&i| r(x_all[i])).collect() } else { idx.iter().map(|&i| i as f64).collect() };- out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(db[i])).collect::<Vec<_>>(), "phase": idx.iter().map(|&i| r(ph[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": "dB"}));- }- }- (out, missing)-}--/// The summary as CSV: the sweep, then a column per probe (dB and phase for a complex one).-pub fn csv(summary: &Value) -> String {- let sweep: Vec<f64> = summary["sweep"]["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();- let probes = summary["probes"].as_array().cloned().unwrap_or_default();- let mut head = vec![summary["sweep"]["name"].as_str().unwrap_or("index").to_string()];- let mut cols: Vec<Vec<f64>> = Vec::new();- for p in &probes {- let n = p["name"].as_str().unwrap_or("");- if p["complex"] == json!(true) {- head.push(format!("{n} dB"));- head.push(format!("{n} deg"));- cols.push(p["db"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());- cols.push(p["phaseDeg"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());- } else {- head.push(n.to_string());- cols.push(p["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());- }- }- let rows = cols.iter().map(Vec::len).chain(std::iter::once(sweep.len())).max().unwrap_or(0);- let mut s = head.join(",") + "\n";- for i in 0..rows {- let mut row = vec![sweep.get(i).map(|v| v.to_string()).unwrap_or_else(|| i.to_string())];- for c in &cols {- row.push(c.get(i).map(|v| v.to_string()).unwrap_or_default());- }- s += &(row.join(",") + "\n");- }- s-}--#[cfg(test)]-mod tests {- use super::*;- #[test] fn the_standard_shape_of_analyses_probes_and_signals() { assert_eq!(analysis_text(&json!({"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1000000})).unwrap(), ".ac dec 20 10 1000000");- assert_eq!(analysis_text(&json!({"type": "ac", "sweep": "dec", "points": 1, "start": 1000, "stop": 1000})).unwrap(), ".ac lin 1 1000 1000", "one frequency is one linear point"); assert_eq!(analysis_text(&json!({"type": "tran", "step": 1e-5, "stop": 0.005})).unwrap(), ".tran 0.00001 0.005"); assert_eq!(analysis_text(&json!({"type": "dc", "source": "V1", "start": 0, "stop": 5, "step": 0.1})).unwrap(), ".dc V1 0 5 0.1");- assert_eq!(analysis_text(&json!({"type": "op"})).unwrap(), ".op");- assert_eq!(analysis_text(&json!({"type": "raw", "text": ".noise v(out) V1 dec 10 1 1meg"})).unwrap(), ".noise v(out) V1 dec 10 1 1meg");- assert!(analysis_text(&json!({"type": "ac", "points": 20})).is_err());- assert_eq!(probe_vector(&json!({"net": "OUT"})).unwrap(), "out");- assert_eq!(probe_vector(&json!({"current": "V1"})).unwrap(), "v1#branch");- // a spike survives decimation- let mut y = vec![0.0; 1000];- y[517] = 5.0;- let k = keep_extrema(&y, 20);- assert!(k.len() <= 20 && k.contains(&517) && k.contains(&0) && k.contains(&999), "{k:?}");- assert_eq!(unwrap_deg(&[170.0, -170.0, -150.0]), vec![170.0, 190.0, 210.0]);- let run = json!({"vectors": [{"name": "frequency", "re": [10.0, 100.0], "im": [0.0, 0.0]}, {"name": "/out", "re": [1.0, 0.0], "im": [0.0, -0.1]}, {"name": "v1#branch", "re": [0.001, 0.001], "im": [0.0, 0.0]}]});- let (sig, missing) = signals(&run, &["out".into(), "nope".into()], 200);- assert_eq!(sig[0]["signal"], "V(OUT)");- assert_eq!((sig[0]["xUnit"].as_str(), sig[0]["yUnit"].as_str()), (Some("Hz"), Some("dB")));- assert_eq!(sig[0]["y"], json!([0.0, -20.0]));- assert_eq!(sig[0]["phase"], json!([0.0, -90.0]));- assert_eq!(missing, vec!["nope".to_string()]);- let (all, _) = signals(&run, &[], 200);- assert!(all.iter().any(|s| s["signal"] == "I(V1)" && s["yUnit"] == "dB"));- }-- const NET: &str = ".title KiCad schematic\nV1 /IN 0 DC 0 SIN( 0 1 1k ) AC 1\nR1 /IN /OUT 1k\nC1 /OUT 0 100n\n.tran 1u 2m\n.end\n";-- #[test]- fn the_call_s_analysis_replaces_the_schematic_s() {- let d = describe(NET);- assert_eq!(d["elements"], 3);- assert_eq!(d["analyses"], json!([".tran 1u 2m"]));- let p = prepare(NET, &[".ac dec 10 10 100k".into()]).unwrap();- assert!(p.contains(&"* replaced by the call: .tran 1u 2m".to_string()));- assert_eq!(p[p.len() - 2], ".ac dec 10 10 100k");- assert_eq!(p.last().unwrap(), ".end");- assert_eq!(prepare(NET, &[]).unwrap().iter().filter(|l| *l == ".tran 1u 2m").count(), 1, "the schematic's own analysis runs");- assert!(prepare("R1 a 0 1k\n.end\n", &[]).is_err(), "no analysis anywhere");- assert!(prepare(NET, &["R2 a b 1".into()]).is_err());- }-- #[test]- fn probes_are_named_the_way_ngspice_names_vectors() {- assert_eq!(vector_name("V(/OUT)"), "/out");- assert_eq!(vector_name("I(V1)"), "v1#branch");- let run = json!({"plot": "tran1", "vectors": [- {"name": "time", "real": [0.0, 1.0, 2.0, 3.0, 4.0]},- {"name": "/out", "real": [0.0, 0.5, 0.75, 0.875, 0.9375]},- {"name": "v1#branch", "real": [0.0, -1.0, -0.5, -0.25, -0.125]}]});- let s = summarize(&run, &["V(/OUT)".into(), "v(/nope)".into()], 3).unwrap();- assert_eq!(s["probes"][0]["name"], "/out");- assert_eq!(s["probes"][0]["values"], json!([0.0, 0.75, 0.9375]));- assert_eq!(s["probes"][0]["max"], 0.9375);- assert_eq!(s["sweep"]["values"], json!([0.0, 2.0, 4.0]));- assert_eq!(s["missing"], json!(["/nope"]));- assert!(csv(&s).starts_with("time,/out\n0,0\n2,0.75\n"));- // AC: magnitude in dB and phase- let ac = json!({"plot": "ac1", "vectors": [{"name": "frequency", "re": [10.0, 100.0], "im": [0.0, 0.0]}, {"name": "/out", "re": [1.0, 0.0], "im": [0.0, -0.1]}]});- let a = summarize(&ac, &["/out".into()], 10).unwrap();- assert_eq!(a["probes"][0]["db"], json!([0.0, -20.0]));- assert_eq!(a["probes"][0]["phaseDeg"], json!([0.0, -90.0]));- assert!(summarize(&run, &["v(/x)".into()], 3).is_err());- }-- #[test]- fn kicads_stubs_for_parts_without_a_model_are_dropped() {- // exported by kicad-cli 10.0.7 for an RC with a connector on its output- let net = ".title KiCad schematic\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nJ1 __J1\nC1 /OUT GND 100n\nR1 /IN /OUT 1k\n.end\n";- let (kept, dropped) = strip_unmodelled(net);- assert_eq!(dropped, vec!["J1".to_string()]);- assert!(!kept.contains("__J1") && kept.contains("R1 /IN /OUT 1k") && kept.ends_with(".end\n"));- let refs: Vec<String> = ["V1", "J1", "C1", "R1"].iter().map(|s| s.to_string()).collect();- assert_eq!(not_simulated(&kept, &refs), vec!["J1".to_string()]);- }-- #[test]- fn parts_kicad_left_out_of_the_netlist_are_named() {- let net = ".title RC\n* U2 has no model\nR1 IN OUT 1k\nC1 OUT 0 100n\nXU1 OUT 0 VCC opamp\nVV1 IN 0 AC 1\n.end\n";- let refs: Vec<String> = ["R1", "C1", "U1", "V1", "U2", "J1"].iter().map(|s| s.to_string()).collect();- assert_eq!(not_simulated(net, &refs), vec!["U2".to_string(), "J1".to_string()]);- assert_eq!(not_simulated(net, &refs[..4]), Vec::<String>::new());- }-- #[test]- fn sweeps_step_values_the_way_spice_does() {- assert_eq!(sweep_values(300.0, 900.0, 300.0, "lin", 50).unwrap(), vec![300.0, 600.0, 900.0]);- let d = sweep_values(100e-12, 100e-6, 1.0, "dec", 50).unwrap();- assert_eq!(d.len(), 7);- assert!((d[6] - 100e-6).abs() < 1e-15);- assert_eq!(sweep_values(1.0, 8.0, 1.0, "oct", 50).unwrap().len(), 4);- assert!(sweep_values(0.0, 100.0, 1.0, "lin", 50).unwrap_err().contains("at most 50"));- assert!(sweep_values(1.0, 2.0, 1.0, "log", 50).is_err());- assert_eq!((spice_num(&json!("100p")), spice_num(&json!("1meg")), spice_num(&json!("4.7k")), spice_num(&json!(2.5))), (Some(100e-12), Some(1e6), Some(4700.0), Some(2.5)));- assert_eq!((spice_label(300.0), spice_label(4700.0), spice_label(1e-10), spice_label(-10.0)), ("300".to_string(), "4.7k".to_string(), "100p".to_string(), "-10".to_string()));- }-- #[test]- fn a_sweep_value_is_an_alter_on_the_element_kicad_named() {- let net = ".title amp\n.model QNPN NPN(BF=100)\nR4 /A /B 1k\nQ1 /C /B /E QNPN\nXU1 /A /B /C opamp\n.end\n";- let m = model_names(net);- assert_eq!(sweep_command(net, "R4", 300.0, &m).unwrap(), "alter R4 = 3e2");- assert_eq!(sweep_command(net, "Q1.BF", 50.0, &m).unwrap(), "altermod QNPN BF = 5e1");- assert!(sweep_command(net, "R9", 1.0, &m).unwrap_err().contains("R9"));- assert!(sweep_command(net, "U1.GAIN", 1.0, &m).is_err());- let t = with_temp(&["R1 a 0 1k".to_string(), ".temp 27".to_string(), ".end".to_string()], 85.0);- assert_eq!(t, vec!["R1 a 0 1k".to_string(), ".temp 85".to_string(), ".end".to_string()]);- }-- #[test]- fn noise_is_an_analysis_on_the_netlists_node_names() {- let net = ".title amp\nV1 /IN GND DC 0 AC 1\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nV2 /VP GND DC 12\n.end\n";- assert_eq!(resolve_node(net, "OUT"), "/OUT");- assert_eq!(resolve_node(net, "GND"), "GND");- let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "sweep": "dec", "points": 10, "start": 10, "stop": 1e6});- assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT) V1 dec 10 10 1000000");- let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "reference": "/IN", "start": 10, "stop": 100});- assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT,/IN) V1 dec 10 10 100");- let run = json!({"vectors": [{"name": "frequency", "real": [10.0, 100.0]}, {"name": "onoise_spectrum", "real": [4e-9, 4e-9]},- {"name": "inoise_spectrum", "real": [4e-9, 4e-9]}, {"name": "onoise_total", "real": [1.2e-6]}, {"name": "inoise_total", "real": [1.3e-6]}]});- let (sig, _) = signals(&run, &[], 200);- assert_eq!(sig.iter().map(|s| (s["signal"].as_str().unwrap(), s["yUnit"].as_str().unwrap())).collect::<Vec<_>>(), vec![("onoise", "V/rtHz"), ("inoise", "V/rtHz")]);- assert_eq!(noise_totals(&run), json!({"onoise": 1.2e-6, "inoise": 1.3e-6}));- }-- #[test]- fn monte_carlo_draws_within_tolerance_and_repeats_with_its_seed() {- let net = ".title amp\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nV2 /VP GND DC 12\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nXU1 a b c opamp\n.end\n";- let parts = mc_parts(net);- assert_eq!(parts.iter().map(|p| (p.0.as_str(), p.1)).collect::<Vec<_>>(), vec![("V2", "dcSource"), ("R1", "resistor"), ("C1", "capacitor")]);- let tol = json!({"resistor": 5, "capacitor": 10});- let a = mc_draws(&parts, &tol, 50, false, 7);- assert_eq!(a, mc_draws(&parts, &tol, 50, false, 7));- assert_ne!(a, mc_draws(&parts, &tol, 50, false, 8));- for run in &a {- assert_eq!(run.len(), 2, "V2 has no tolerance");- assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12 && (run[1].1 / 100e-9 - 1.0).abs() <= 0.10 + 1e-12);- }- for run in mc_draws(&parts, &tol, 50, true, 7) {- assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12);- }- assert_eq!((sig6(9038.123), sig6(1.0004e-10), sig6(12.4331)), ("9038.12".to_string(), "1.00040e-10".to_string(), "12.4331".to_string()));- }-}+ assert_eq!(analysis_text(&json!({"type": "op"})).unwrap(), ".op");
rust/crates/kicad-platform/Cargo.toml+2−47@@ -1,51 +1,6 @@-[package]-name = "kicad-platform"-description = "OS-specific layer of the Adom KiCad Bridge: one Platform trait, one implementation compiled in per target OS"-version.workspace = true-edition.workspace = true-license.workspace = true-publish.workspace = true--[dependencies]-serde = { workspace = true }-serde_json = { workspace = true }--# Windows-only crates. Add uiautomation and windows here in phase 3.-[target.'cfg(windows)'.dependencies]-winreg = "0.55"-# Phase 3: window control (EnumWindows, menus, PostMessage, WinEventHook, placement,-# process queries) and UI Automation. Feature lists stay minimal on purpose.-windows = { version = "0.61", features = [- "Win32_Foundation",- "Win32_UI_WindowsAndMessaging",- "Win32_UI_Input_KeyboardAndMouse",- "Win32_UI_Accessibility",- "Win32_System_Threading",- "Win32_System_ProcessStatus",- "Win32_System_Diagnostics_ToolHelp",- "Win32_Graphics_Gdi",- "Win32_Graphics_Dwm",- "Win32_UI_Shell",- "Win32_UI_HiDpi",- "Win32_System_Com",- "Win32_System_Variant", # KiCad shortcut AUMID repair (win/shortcuts.rs)- "Win32_System_Com_StructuredStorage", # KiCad shortcut AUMID repair (win/shortcuts.rs)- "Win32_UI_Shell_PropertiesSystem", # KiCad shortcut AUMID repair (win/shortcuts.rs)- "Win32_Security", # SECURITY_ATTRIBUTES in the CreateProcessW signature (spawn_background)- "Win32_Storage_Xps", # PrintWindow lives here in the windows crate (capture_window)- "Win32_System_SystemInformation", # GetTickCount (seconds_since_input)- "Win32_Media_Multimedia", # mciSendStringW: tour narration audio without a window (tour_input)- "Win32_System_LibraryLoader", # LoadLibraryExW/GetProcAddress: KiCad's ngspice.dll for kicad_simulate (ngspice.rs)- "Win32_Storage_FileSystem", # GetLogicalDrives/GetDriveTypeW: KiCad installed on another drive (wiki #137)-] }-uiautomation = "0.24"- # macOS-only crates go here (Kyle). Candidates: objc2, core-foundation, accessibility. [target.'cfg(target_os = "macos")'.dependencies] -# Linux-only crates go here. x11rb is pure Rust (no libX11/libxcb link): KiCad's windows are X11-# windows on X11 desktops and XWayland windows on Wayland desktops (src/linux/x11.rs).+# Linux-only crates go here (Barrett). Candidates: x11rb, zbus (portal screenshots), atspi. [target.'cfg(target_os = "linux")'.dependencies]-x11rb = { version = "0.13", default-features = false, features = ["screensaver", "xtest"] }-# AT-SPI (the accessibility bus, Linux's UI Automation): dialog text, buttons, menus. Pure Rust D-Bus.-zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] }+-- /dev/null
rust/crates/kicad-platform/src/lib.rs−407@@ -1,424 +1,17 @@-//! The OS layer. Everything above this crate is OS-neutral: the HTTP server, the verb-//! catalog, kicad-cli parsing, KiCad file editing and (phase 2) the IPC client.-//!-//! Exactly one `Native` implementation is compiled in, picked by `cfg(target_os)`.-//! Each OS file implements the `Platform` trait. A method an OS does not support yet-//! returns `unsupported(...)`, which carries an honest `not_supported_on_platform`-//! error and names the owner, so a verb never pretends.-//!-//! Ownership: windows.rs (this thread), macos.rs (Kyle), linux.rs (Barrett).--use std::path::{Path, PathBuf};-use std::process::Command;--use serde::Serialize;-use serde_json::{json, Value};--#[derive(Clone, Debug, Default, Serialize)]-pub struct KicadInstall {- pub version: String,- #[serde(rename = "baseDir")]- pub base_dir: String,- #[serde(rename = "binDir")]- pub bin_dir: String,- #[serde(rename = "kicadCli")]- pub kicad_cli: String,- #[serde(rename = "cliVersion")]- pub cli_version: String,- pub source: String,- #[serde(rename = "perUser")]- pub per_user: bool,-}--/// What this build of the bridge can do on this OS. Reported by readiness and-/// diagnostics so an AI never assumes a Windows-only verb works on a Mac.-#[derive(Clone, Debug, Serialize)]-pub struct Capabilities {- pub os: &'static str,- pub owner: &'static str,- #[serde(rename = "kicadCli")]- pub kicad_cli: bool,- #[serde(rename = "windowControl")]- pub window_control: bool,- #[serde(rename = "uiAutomation")]- pub ui_automation: bool,- #[serde(rename = "dialogSweep")]- pub dialog_sweep: bool,- #[serde(rename = "focusEtiquette")]- pub focus_etiquette: bool,- #[serde(rename = "ipcApi")] pub ipc_api: bool, #[serde(rename = "silentInstall")] pub silent_install: bool,- /// Linux: the desktop session, "x11" or "wayland" ("none" without a display). KiCad's- /// windows are X windows in both (XWayland on Wayland). Empty, and omitted, elsewhere.- #[serde(skip_serializing_if = "str::is_empty")]- pub session: &'static str, } /// One top-level (or child) window as the OS reports it.-#[derive(Clone, Debug, Default, Serialize)]-pub struct WindowInfo {- pub hwnd: u64,- pub title: String,- #[serde(rename = "className")]- pub class_name: String,- pub pid: u32,- /// Executable base name, lower case (kicad.exe, pcbnew.exe, ...).- pub exe: String,- pub visible: bool,- /// Owner window handle (0 for an unowned top level). Dialogs are owned.- pub owner: u64,- /// x, y, width, height in physical pixels.- pub rect: (i32, i32, i32, i32),- pub minimized: bool,- pub maximized: bool,- /// The window's thread did not answer a WM_NULL within the probe deadline.- pub hung: bool,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct MenuItem {- /// Menu path, top level first ("View", "3D Viewer").- pub path: Vec<String>,- pub id: u32,- /// Accelerator text after the tab ("Alt+3", "Ctrl+Shift+E"), if any.- pub accel: Option<String>,-}--#[derive(Clone, Debug, Default, PartialEq, Serialize)]-pub struct Placement {- pub x: i32,- pub y: i32,- pub width: i32,- pub height: i32,- pub maximized: bool,-}--#[derive(Clone, Debug, Default, Serialize)]-pub struct ProcessInfo {- pub pid: u32,- pub exe: String,- pub path: String,- #[serde(rename = "parentPid")]- pub parent_pid: u32,- #[serde(rename = "startedAt")]- pub started_at: Option<u64>,-}--/// A captured window bitmap, top-down RGB8, for the canvas-painted probe.-pub struct Capture {- pub width: u32,- pub height: u32,- pub rgb: Vec<u8>,-}--fn nope<T>(what: &str) -> Result<T, String> {- Err(format!("{what}: not implemented on {}", native().capabilities().os))-}--pub trait Platform: Sync + Send {- fn capabilities(&self) -> Capabilities;- /// `kicad-cli.exe` on Windows, `kicad-cli` elsewhere.- fn cli_file_name(&self) -> &'static str;- /// Every KiCad install this OS can see. Order does not matter; the core sorts by version.- fn find_installs(&self) -> Vec<KicadInstall>;- /// Per-user settings dir for a KiCad major.minor (where kicad_common.json lives).- fn config_dir(&self, version: &str) -> Option<PathBuf>;- /// Per-user documents dir KiCad uses for libraries and 3rdparty (Documents/KiCad/<ver>).- fn user_dir(&self, version: &str) -> Option<PathBuf>;- /// Make a child process invisible and detached from any console.- fn quiet_command(&self, cmd: &mut Command);- /// Parent of every per-version settings dir (all KiCad versions the user ever ran).- fn config_root(&self) -> Option<PathBuf>;- /// Parent of every per-version documents dir (Documents/KiCad).- fn user_root(&self) -> Option<PathBuf>;- /// The user's Documents folder (where the bridge keeps demo and staging projects).- fn documents_dir(&self) -> Option<PathBuf>;- /// Is a process with this id alive? None when the OS layer cannot tell.- fn process_alive(&self, pid: u32) -> Option<bool>;-- // ---- Phase 3: window control. Every default says "not implemented here" so an- // OS file only implements what it supports; `capabilities()` tells callers which.-- /// Every visible top-level window.- fn list_windows(&self) -> Result<Vec<WindowInfo>, String> { nope("list_windows") }- /// One window by handle (visible or not).- fn window_info(&self, _hwnd: u64) -> Result<Option<WindowInfo>, String> { nope("window_info") }- /// Every descendant of a window (class name and text) with `owner` set to its direct parent,- /// for dialog buttons and the library-tree filter box (join on class and parent title).- fn child_windows(&self, _hwnd: u64) -> Result<Vec<WindowInfo>, String> { nope("child_windows") }- /// Running processes whose executable lives under `bin_dir` (or any KiCad exe when None).- fn processes(&self, _exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> { nope("processes") }- /// Spawn a GUI program without activating its window (SW_SHOWNOACTIVATE), stderr to a log.- fn spawn_background(&self, _exe: &Path, _args: &[String], _env: &[(String, String)], _log: Option<&Path>) -> Result<u32, String> { nope("spawn_background") }- fn kill_process(&self, _pid: u32) -> Result<(), String> { nope("kill_process") }- /// Does the window's thread answer a WM_NULL within `timeout_ms`?- fn responsive(&self, _hwnd: u64, _timeout_ms: u32) -> Result<bool, String> { nope("responsive") }- /// Post WM_CLOSE (no wait).- fn post_close(&self, _hwnd: u64) -> Result<(), String> { nope("post_close") }- /// Walk the native menu bar.- fn menu_tree(&self, _hwnd: u64) -> Result<Vec<MenuItem>, String> { nope("menu_tree") }- /// Post WM_COMMAND with a menu id (background, no focus).- fn menu_invoke(&self, _hwnd: u64, _id: u32) -> Result<(), String> { nope("menu_invoke") }- /// Post a key (WM_KEYDOWN, optional WM_CHAR, WM_KEYUP) to one window's queue.- fn post_key(&self, _hwnd: u64, _vk: u16, _ch: Option<char>) -> Result<(), String> { nope("post_key") }- /// Post a mouse click at client coordinates to one window's queue.- fn post_click(&self, _hwnd: u64, _x: i32, _y: i32, _double: bool) -> Result<(), String> { nope("post_click") }- /// WM_SETTEXT on an edit control.- fn set_text(&self, _hwnd: u64, _text: &str) -> Result<(), String> { nope("set_text") }- /// Text of a control (WM_GETTEXT with a deadline; never blocks on a hung window).- fn window_text(&self, _hwnd: u64, _timeout_ms: u32) -> Result<String, String> { nope("window_text") }- /// Click a button child by label: BM_CLICK with a timeout first, UIA Invoke second.- /// Returns the method used.- fn click_button(&self, _dialog: u64, _labels: &[&str]) -> Result<String, String> { nope("click_button") }- /// UI Automation: invoke the first descendant whose name contains `name`.- fn uia_invoke(&self, _hwnd: u64, _name: &str) -> Result<String, String> { nope("uia_invoke") }- /// UI Automation: ValuePattern.SetValue on the descendant whose name contains `name`.- fn uia_set_value(&self, _hwnd: u64, _name: &str, _text: &str) -> Result<(), String> { nope("uia_set_value") }- /// Set a dialog's check box (exact caption) on or off; "unchanged:" when it already was. fn uia_set_checked(&self, _hwnd: u64, _name: &str, _on: bool) -> Result<String, String> { nope("uia_set_checked") } /// UI Automation: names of descendants matching a substring (bounded). fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") }- /// Activate (as a double-click does) the selected row of the first tree in the window: the- /// library tree of the symbol and footprint editors. Linux (AT-SPI); Windows navigates with keys.- fn tree_activate_selected(&self, _hwnd: u64) -> Result<String, String> { nope("tree_activate_selected") } fn foreground(&self) -> Result<u64, String> { nope("foreground") } /// The window on top of the Z order (first visible, non-minimized, titled top-level /// window), which is what the user sees covering everything else; it can differ from- /// the foreground (activated) window when a window raised itself without activation.- fn z_top(&self) -> Result<u64, String> { nope("z_top") }- /// Push a window to the bottom of the z-order without activating anything.- fn push_to_background(&self, _hwnd: u64) -> Result<(), String> { nope("push_to_background") }- /// Show without activating (SW_SHOWNOACTIVATE).- fn show_no_activate(&self, _hwnd: u64) -> Result<(), String> { nope("show_no_activate") }- /// The one sanctioned foreground: restore and activate (AttachThreadInput technique).- fn bring_to_front(&self, _hwnd: u64) -> Result<bool, String> { nope("bring_to_front") }- fn placement(&self, _hwnd: u64) -> Result<Placement, String> { nope("placement") }- fn set_placement(&self, _hwnd: u64, _p: &Placement) -> Result<(), String> { nope("set_placement") }- /// Is any part of the rect on a monitor?- fn on_a_monitor(&self, _rect: (i32, i32, i32, i32)) -> Result<bool, String> { nope("on_a_monitor") }- /// Write KiCad's own AUMID onto its standalone Start Menu shortcuts (the white taskbar- /// icon); one row per shortcut with `written` / `already` / `missing` / `error`.- fn repair_taskbar_shortcuts(&self, _version: &str) -> Result<Value, String> { nope("repair_taskbar_shortcuts") }- /// Capture a window's pixels for the canvas-painted probe (PrintWindow); no focus change.- fn capture_window(&self, _hwnd: u64) -> Result<Capture, String> { nope("capture_window") }- /// The GL canvas child rect (class wxGLCanvas) in window-client coordinates, if any. fn gl_canvas_rect(&self, _hwnd: u64) -> Result<Option<(i32, i32, i32, i32)>, String> { nope("gl_canvas_rect") } /// Seconds since the user last touched keyboard or mouse. fn seconds_since_input(&self) -> Result<f64, String> { nope("seconds_since_input") }- /// KiCad's ngspice shared library for kicad_simulate, given the install's kicad-cli: Windows ships- /// `<bin>/ngspice.dll` beside it; Linux uses the system's libngspice.- fn ngspice_library(&self, kicad_cli: &Path) -> PathBuf {- kicad_cli.parent().unwrap_or(Path::new(".")).join("ngspice.dll")- }- /// Where KiCad's XSPICE code models (*.cm) live: `<kicad>/lib/ngspice` on Windows.- fn ngspice_codemodel_dir(&self, kicad_cli: &Path) -> PathBuf {- kicad_cli.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default()- } /// Set process DPI awareness once at startup (no-op where irrelevant). fn init_process(&self) {}-- // ---- Phase 3b: silent KiCad install (kicad_upgrade). The core orchestrates- // (target version, scope, download, verify, seed); the OS file supplies the four- // facts below. Defaults are honest: an OS with no installer path says so.-- /// Elevation and UAC facts for THIS process. Always carries the four summary keys- /// `isAdmin`, `uacEnabled`, `consentPrompt`, `canInstallAllUsersSilently` (null where- /// the OS has no such notion) plus the OS's raw evidence (Windows: the token- /// elevation type and flag, EnableLUA, ConsentPromptBehaviorAdmin).- fn elevation_info(&self) -> Result<Value, String> { nope("elevation_info") }- /// Run a downloaded KiCad installer silently with the given scope and a deadline;- /// the exit code comes back. On timeout the installer is killed and this is Err.- fn run_installer(&self, _path: &Path, _all_users: bool, _timeout_secs: u64) -> Result<i32, String> { nope("run_installer") }- /// What a KiCad installer is on this OS: "nsis-exe" (Windows), "dmg" (macOS),- /// "distro-package" (Linux). Only nsis-exe has a silent path the bridge drives.- fn installer_kind(&self) -> &'static str { "unknown" }- /// The official downloads.kicad.org installer for `version` on this OS, or None- /// when installs go through the OS package path instead (macOS, Linux).- fn installer_download_url(&self, _version: &str) -> Option<String> { None }-- // ---- Phase 4: the etiquette loop (see docs/rust-port-plan.md "Phase 4 specification").- // Defaults do nothing, so an OS without the loop still runs every verb; the Windows- // implementation makes them real.-- /// Start the sentinel and event-hook threads once. Idempotent.- fn etiquette_start(&self) {}- /// Turn the "send a KiCad window the bridge just opened behind the user's window" guard on or- /// off (setting `backgroundGuard`). Off means KiCad windows are never moved or pushed by the- /// bridge at all; events are still logged so the two modes can be compared.- fn etiquette_set_enabled(&self, _on: bool) {}- /// Whether the background guard is on (setting `backgroundGuard`).- fn etiquette_enabled(&self) -> bool { true }- /// A Windows shell window (taskbar, Start, desktop, anything explorer.exe owns). The bridge- /// never forces one of these to the foreground: doing so crashed Explorer (Taskbar.View.dll).- fn is_shell_window(&self, _h: u64) -> bool { false }- /// Ask (never force) for `h` to become the foreground window. True when Windows agreed.- fn request_foreground(&self, _h: u64) -> bool { false }- /// Cancel Windows' taskbar attention flash on bridge-owned KiCad windows (setting- /// `activityIndicator`: progress and none suppress it, flash and both leave it).- fn etiquette_set_flash_suppressed(&self, _on: bool) {}- /// A window verb begins (arms the guard, snapshots the park baseline). `spawns` = the- /// verb opens a new window and gets the long guard.- fn etiquette_begin_verb(&self, _verb: &str, _spawns: bool) {}- fn etiquette_end_verb(&self, _verb: &str) {}- /// Record a KiCad process (and its children) as bridge-owned, persisted to disk.- fn etiquette_register_pid(&self, _pid: u32, _project: Option<&Path>) {}- /// A foreground the bridge itself sanctioned (bring_to_front with a reason): the loop- /// ignores this window for `seconds`.- fn etiquette_sanction(&self, _hwnd: u64, _seconds: f64) {}- /// Recent focus transitions with their classification (the Python's focusEvents).- fn etiquette_events(&self) -> Vec<Value> { Vec::new() }- /// The Python's focusDebug block.- fn etiquette_debug(&self) -> Value { Value::Null }- /// Is this hwnd bridge-owned according to the ledger? None when there is no ledger.- fn etiquette_owned(&self, _hwnd: u64) -> Option<bool> { None }-- // ---- Phase 5: real input for the demo tour only. Every call must refuse unless- // `hwnd` is the measured foreground window (the tour foregrounds its beat once, with a- // reason); nothing else in the bridge may synthesise input.-- /// Move the cursor to screen coordinates (absolute, virtual desktop).- fn tour_mouse_move(&self, _hwnd: u64, _x: i32, _y: i32) -> Result<(), String> { nope("tour_mouse_move") }- /// Press or release a mouse button: "left" | "middle" | "right".- fn tour_mouse_button(&self, _hwnd: u64, _button: &str, _down: bool) -> Result<(), String> { nope("tour_mouse_button") }- /// Wheel clicks, positive = up.- fn tour_wheel(&self, _hwnd: u64, _clicks: i32) -> Result<(), String> { nope("tour_wheel") }- /// Press and release a virtual key.- fn tour_key(&self, _hwnd: u64, _vk: u16) -> Result<(), String> { nope("tour_key") }- /// Play an audio file without a window (narration). Returns a handle for tour_audio_stop.- fn tour_audio_play(&self, _path: &Path, _volume: f32) -> Result<u64, String> { nope("tour_audio_play") }- fn tour_audio_stop(&self, _handle: u64) -> Result<(), String> { nope("tour_audio_stop") }-- // ---- Freerouting (kicad_autoroute engine "freerouting"). The core orchestrates- // (status, download, verify, run, apply); the OS file supplies the release asset,- // how it unpacks, and where it lives. Nothing here runs unless the core was asked.-- /// The Freerouting 2.4.1 release asset for this OS as (url, exact byte size, kind),- /// kind "msi" (Windows) or "zip" (Linux). None where the bridge has no non-interactive- /// path (macOS ships a dmg). Every build bundles its own Java runtime.- fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> { None }- /// Unpack a downloaded bundle into `dest` and return the executable. `unzip` is the- /// core's std-only zip reader (extract archive into a directory) for the zip kinds;- /// Windows runs `msiexec /a` instead. Must finish within a deadline of its own.- fn freerouting_extract(&self, _archive: &Path, _dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> { nope("freerouting_extract") }- /// Where the bridge keeps Freerouting: `%LOCALAPPDATA%\Adom Bridge\freerouting` on- /// Windows, `~/.adom/freerouting` elsewhere (a temp dir when HOME is unset).- fn freerouting_dir(&self) -> PathBuf {- std::env::var_os("HOME").map(PathBuf::from).filter(|h| !h.as_os_str().is_empty()).unwrap_or_else(std::env::temp_dir).join(".adom").join("freerouting")- }-}--/// Standard "this build cannot do that here" reply.-pub fn unsupported(feature: &str) -> Value {- let caps = native().capabilities();- json!({- "success": false,- "errorCode": "not_supported_on_platform",- "error": format!("{feature} is not implemented in the {} build of the KiCad bridge yet", caps.os),- "platform": caps.os,- "_hint": format!("The {} port is owned by {}. Headless verbs (kicad-cli, file edits) work everywhere; window verbs are per OS.", caps.os, caps.owner),- })-}--/// Shared helper: describe an install from its kicad-cli path, probing the exact version.-pub fn install_from_cli(cli: &std::path::Path, version_hint: &str, source: &str, per_user: bool) -> Option<KicadInstall> {- if !cli.is_file() {- return None;- }- let mut cmd = Command::new(cli);- cmd.arg("version");- native().quiet_command(&mut cmd);- let cli_version = match cmd.output() {- Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),- _ => String::new(),- };- let bin = cli.parent()?.to_path_buf();- let base = bin.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| bin.clone());- let version = if !version_hint.is_empty() {- version_hint.to_string()- } else {- cli_version.split('.').take(2).collect::<Vec<_>>().join(".")- };- Some(KicadInstall {- version,- base_dir: norm(&base),- bin_dir: norm(&bin),- kicad_cli: norm(cli),- cli_version,- source: source.into(),- per_user,- })-}--/// Where a KiCad install can sit under each drive root, beyond what the registry names (wiki #137: KiCad on D:):-/// `<root>\Program Files\KiCad`, `<root>\Program Files (x86)\KiCad` and `<root>\KiCad`, each holding version folders.-pub fn drive_install_bases(roots: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {- roots.iter().flat_map(|r| [r.join("Program Files").join("KiCad"), r.join("Program Files (x86)").join("KiCad"), r.join("KiCad")]).collect()-}--/// The version folders under one install base that hold `bin/<cli_name>`: (folder name, the kicad-cli path).-pub fn version_dirs(base: &std::path::Path, cli_name: &str) -> Vec<(String, std::path::PathBuf)> {- let Ok(rd) = std::fs::read_dir(base) else { return Vec::new() };- let mut out: Vec<(String, std::path::PathBuf)> = rd- .flatten()- .filter(|e| e.path().is_dir())- .map(|e| (e.file_name().to_string_lossy().to_string(), e.path().join("bin").join(cli_name)))- .filter(|(name, cli)| name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && cli.is_file())- .collect();- out.sort();- out-}--pub fn norm(p: &std::path::Path) -> String {- p.to_string_lossy().replace('\\', "/")-}--pub fn major_minor(version: &str) -> String {- version.split('.').take(2).collect::<Vec<_>>().join(".")-}--/// SPICE through KiCad's ngspice library (kicad_simulate).-pub mod ngspice;--#[cfg(windows)]-mod windows;-#[cfg(windows)]-pub use windows::Native;--#[cfg(target_os = "macos")]-mod macos;-#[cfg(target_os = "macos")]-pub use macos::Native;--#[cfg(target_os = "linux")]-mod linux;-#[cfg(target_os = "linux")]-pub use linux::Native;--static NATIVE: Native = Native;--pub fn native() -> &'static dyn Platform {- &NATIVE-}--#[cfg(test)]-mod install_root_tests {- use super::*;-- #[test]- fn a_kicad_on_another_drive_is_found_under_its_root() {- // a fake drive root (wiki #137: D:\Program Files\KiCad\9.0 and D:\KiCad\10.0)- let root = std::env::temp_dir().join(format!("kb-drive-{}", std::process::id()));- for v in [root.join("Program Files/KiCad/9.0/bin"), root.join("KiCad/10.0/bin"), root.join("KiCad/notes")] {- std::fs::create_dir_all(&v).unwrap();- }- std::fs::write(root.join("Program Files/KiCad/9.0/bin/kicad-cli.exe"), "").unwrap();- std::fs::write(root.join("KiCad/10.0/bin/kicad-cli.exe"), "").unwrap();- let bases = drive_install_bases(&[root.clone()]);- assert_eq!(bases.len(), 3);- let found: Vec<String> = bases.iter().flat_map(|b| version_dirs(b, "kicad-cli.exe")).map(|(n, _)| n).collect();- assert_eq!(found, vec!["9.0".to_string(), "10.0".to_string()], "every version folder with a kicad-cli, nothing else");- let _ = std::fs::remove_dir_all(&root);- }-}
rust/crates/kicad-platform/src/linux.rs+13−447@@ -1,494 +1,60 @@-//! Linux implementation. Headless parts: kicad-cli on PATH, /usr and /usr/local, snap and-//! flatpak locations, XDG config, ~/Documents/KiCad/<ver>. Window control: `x11.rs`, the-//! X server's view of KiCad's windows, which covers X11 desktops and Wayland desktops alike-//! (KiCad runs under XWayland there).--mod atspi;-mod x11;-use x11::session_type;+//! Linux implementation (owner: Barrett). Headless parts are complete: kicad-cli on PATH,+//! /usr and /usr/local, snap and flatpak locations, XDG config, ~/Documents/KiCad/<ver>.+//! Window control on Linux depends on the session (X11 versus Wayland portals); add it+//! here in phase 3 and flip the capability flags. use std::path::{Path, PathBuf}; use std::process::Command; use serde_json::{json, Value}; -use crate::{install_from_cli, major_minor, Capabilities, Capture, KicadInstall, MenuItem, Placement, Platform, ProcessInfo, WindowInfo};+use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform, ProcessInfo}; pub struct Native; -/// The background guard (setting `backgroundGuard`), off by default as on Windows (wiki #114).-/// KiCad still opens in the background on Linux without it: see spawn_background.-static GUARD_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);- fn home() -> Option<PathBuf> { std::env::var("HOME").ok().map(PathBuf::from) }--impl Platform for Native { fn capabilities(&self) -> Capabilities { Capabilities { os: "linux",- owner: "the KiCad bridge's Linux build",+ owner: "Barrett (Ubuntu build of the bridge)", kicad_cli: true,- // KiCad's windows are X windows on every Linux desktop (XWayland on Wayland).- window_control: std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),- // AT-SPI on the session's accessibility bus (atspi.rs): dialog text and buttons, menus.- ui_automation: std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(),- dialog_sweep: std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(),+ window_control: false,+ ui_automation: false,+ dialog_sweep: false, focus_etiquette: false, ipc_api: true, silent_install: false,- session: session_type(), } } fn cli_file_name(&self) -> &'static str {- "kicad-cli"- }- fn find_installs(&self) -> Vec<KicadInstall> {- let mut out = Vec::new();- // Wiki #127: a Flatpak KiCad has no kicad-cli on the host. The bridge writes its own- // launchers (kicad-cli, kicad, pcbnew, eeschema) that run the Flatpak with the host's- // files and /tmp shared, so every path the bridge hands kicad-cli resolves inside the- // sandbox, and the API socket KiCad opens under /tmp/kicad is the host's.- if let Some((installation, bin)) = flatpak::ensure_wrappers() {- if let Some(inst) = install_from_cli(&bin.join("kicad-cli"), "", installation, installation == "flatpak (user)") {- out.push(inst);- }- }- let mut candidates: Vec<(PathBuf, &str)> = vec![- (PathBuf::from("/usr/bin/kicad-cli"), "distro package"),- (PathBuf::from("/usr/local/bin/kicad-cli"), "local build"),- (PathBuf::from("/snap/bin/kicad-cli"), "snap"),- ];- if let Some(h) = home() {- candidates.push((h.join(".local/bin/kicad-cli"), "~/.local/bin"));- }- if let Ok(path) = std::env::var("PATH") {- for dir in path.split(':') {- candidates.push((PathBuf::from(dir).join("kicad-cli"), "PATH"));- } } let have_flatpak = !out.is_empty(); for (c, source) in candidates {- // One binary seen twice is one install: /bin is a link to /usr/bin on merged-/usr- // distros (Ubuntu, Debian, Fedora), and PATH names both.- let real = std::fs::canonicalize(&c).unwrap_or_else(|_| c.clone());- if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c) || std::fs::canonicalize(&i.kicad_cli).is_ok_and(|p| p == real)) {+ if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c)) { continue; } // A user's own wrapper around the same Flatpak adds nothing over ours, and theirs- // may not share /tmp with the sandbox.- if have_flatpak && flatpak::is_flatpak_wrapper(&c) {- continue;- }- if let Some(inst) = install_from_cli(&c, "", source, false) {- out.push(inst);- }- }- out- }- fn config_dir(&self, version: &str) -> Option<PathBuf> {- Some(self.config_root()?.join(major_minor(version)))- }- fn user_dir(&self, version: &str) -> Option<PathBuf> {- Some(home()?.join("Documents/KiCad").join(major_minor(version)))- }- fn quiet_command(&self, cmd: &mut Command) {- cmd.stdin(std::process::Stdio::null());- }- fn config_root(&self) -> Option<PathBuf> {- // KiCad's own override wins (it passes into a Flatpak sandbox with the rest of the environment).- if let Ok(d) = std::env::var("KICAD_CONFIG_HOME") {- if !d.is_empty() {- return Some(PathBuf::from(d));- }- }- // A Flatpak KiCad keeps its settings under ~/.var/app/<id>/config, not ~/.config.- if flatpak::installation().is_some() && !native_cli_present() {- return Some(home()?.join(".var/app").join(flatpak::APP_ID).join("config/kicad"));- }- let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;- Some(base.join("kicad"))- }- fn user_root(&self) -> Option<PathBuf> {- Some(home()?.join("Documents/KiCad"))- }- fn documents_dir(&self) -> Option<PathBuf> {- Some(home()?.join("Documents"))- }- fn process_alive(&self, pid: u32) -> Option<bool> {- // A zombie still has a /proc entry; it is not alive.- match std::fs::read_to_string(format!("/proc/{pid}/stat")) {- Ok(stat) => Some(!matches!(stat_field(&stat, 0).as_deref(), Some("Z") | Some("X"))),- Err(_) => Some(false),- }- }- fn processes(&self, exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> {- let wanted: Vec<String> = exe_names.iter().map(|n| n.trim_end_matches(".exe").to_lowercase()).collect();- let mut out = Vec::new();- for e in std::fs::read_dir("/proc").map_err(|e| e.to_string())?.flatten() {- let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else { continue };- let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { continue };- // comm is truncated to 15 bytes; the exe link (when readable) has the full name.- let path = std::fs::read_link(format!("/proc/{pid}/exe")).map(|p| p.to_string_lossy().trim_end_matches(" (deleted)").to_string()).unwrap_or_default();- let comm = stat.get(stat.find('(').map(|i| i + 1).unwrap_or(0)..stat.rfind(')').unwrap_or(0)).unwrap_or("").to_string();- let exe = Path::new(&path).file_name().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or(comm);- if !wanted.is_empty() && !wanted.contains(&exe.to_lowercase()) {- continue;- }- if matches!(stat_field(&stat, 0).as_deref(), Some("Z") | Some("X")) {- continue;- }- let parent_pid = stat_field(&stat, 1).and_then(|s| s.parse().ok()).unwrap_or(0);- out.push(ProcessInfo { pid, exe, path, parent_pid, started_at: None });- }- Ok(out) } fn spawn_background(&self, exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> { use std::os::unix::process::CommandExt;- // Its own systemd scope where the user session has systemd: the bridge, and Adom Bridge- // with it, run inside Adom Bridge's service, and a KiCad left in that service is part of it.- // When the kernel's OOM killer picked a large KiCad there, systemd stopped the whole service- // and Adom Bridge went down with it (measured on a 6 GB VM). In its own scope KiCad- // also outlives a bridge restart. systemd-run --scope execs the program, so the pid is KiCad's.- let mut cmd = match user_systemd_run() {- Some(run) => {- let name = exe.file_name().map(|n| n.to_string_lossy().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect::<String>()).unwrap_or_default();- let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);- let mut c = Command::new(run);- c.args(["--user", "--scope", "--quiet", "--collect", &format!("--unit=adom-kicad-{name}-{stamp}"), "--"]).arg(exe);- c- }- None => Command::new(exe),- };+ let mut cmd = Command::new(exe); cmd.args(args).stdin(std::process::Stdio::null());- // Open in the background: GTK stamps its first windows with the user time named in the- // startup id, and the window manager (Mutter, Muffin, KWin) does not give focus to a- // window whose user time is older than the user's last input. Without this a KiCad the- // bridge starts takes keyboard focus from whatever the user is typing in (measured on- // GNOME Wayland). A foreground the caller asks for, with a reason, is a separate request.- if !env.iter().any(|(k, _)| k == "DESKTOP_STARTUP_ID") {- cmd.env("DESKTOP_STARTUP_ID", "adom-kicad-bridge_TIME1");- } for (k, v) in env { cmd.env(k, v); }- match log.map(std::fs::File::create) {- Some(Ok(f)) => {- let f2 = f.try_clone().map_err(|e| e.to_string())?;- cmd.stdout(f).stderr(f2);- }- _ => {- cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());- }- }- // Its own session: KiCad outlives a bridge restart and never gets the bridge's signals.- cmd.process_group(0);- let mut child = cmd.spawn().map_err(|e| format!("spawn {}: {e}", exe.display()))?;- let pid = child.id();- // Reap it when it exits so it does not linger as a zombie.- std::thread::spawn(move || {- let _ = child.wait();- });- Ok(pid)- }- fn kill_process(&self, pid: u32) -> Result<(), String> {- let st = Command::new("kill").arg("-TERM").arg(pid.to_string()).status().map_err(|e| e.to_string())?;- if !st.success() {- return Err(format!("kill {pid} failed"));- }- for _ in 0..30 {- if self.process_alive(pid) == Some(false) {- return Ok(());- }- std::thread::sleep(std::time::Duration::from_millis(100));- }- let _ = Command::new("kill").arg("-KILL").arg(pid.to_string()).status(); Ok(()) } - // ---- kicad_simulate: KiCad on Linux links the distribution's libngspice ------ fn ngspice_library(&self, _kicad_cli: &Path) -> PathBuf {- const DIRS: &[&str] = &["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib", "/usr/local/lib", "/usr/local/lib64"];- DIRS.iter()- .flat_map(|d| ["libngspice.so.0", "libngspice.so"].map(|n| Path::new(d).join(n)))- .find(|p| p.exists())- .unwrap_or_else(|| PathBuf::from("/usr/lib/x86_64-linux-gnu/libngspice.so.0"))- }- fn ngspice_codemodel_dir(&self, _kicad_cli: &Path) -> PathBuf {- ["/usr/lib/x86_64-linux-gnu/ngspice", "/usr/lib64/ngspice", "/usr/lib/ngspice", "/usr/local/lib/ngspice"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_default()- }-- // ---- Window control (x11.rs) ------ fn list_windows(&self) -> Result<Vec<WindowInfo>, String> {- x11::list_windows()- }- fn window_info(&self, h: u64) -> Result<Option<WindowInfo>, String> {- x11::window_info(h)- }- fn child_windows(&self, h: u64) -> Result<Vec<WindowInfo>, String> {- let mut out = x11::child_windows(h)?;- // A dialog's message lines, from the accessibility bus, as "Static" children: the shape the- // dialog-body reader takes on every OS. (Frames are skipped: their trees are thousands deep.)- if x11::window_info(h)?.is_some_and(|w| w.owner != 0) {- if let Ok(texts) = atspi::dialog_texts(h) {- out.extend(texts.into_iter().map(|t| WindowInfo { hwnd: 0, title: t, class_name: "Static".into(), owner: h, visible: true, ..Default::default() }));- }- }- Ok(out)- }- fn post_key(&self, h: u64, vk: u16, ch: Option<char>) -> Result<(), String> {- x11::post_key(h, vk, ch)- }- fn click_button(&self, dialog: u64, labels: &[&str]) -> Result<String, String> {- atspi::click_button(dialog, labels)- }- fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> {- atspi::invoke(h, name)- }- fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> {- atspi::set_value(h, name, text)- }- fn uia_set_checked(&self, h: u64, name: &str, on: bool) -> Result<String, String> {- atspi::set_checked(h, name, on)- }- fn uia_find(&self, h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {- atspi::find_names(h, name, limit)- }- fn tree_activate_selected(&self, h: u64) -> Result<String, String> {- atspi::tree_activate_selected(h)- }- fn menu_tree(&self, h: u64) -> Result<Vec<MenuItem>, String> {- atspi::menu_tree(h)- }- fn menu_invoke(&self, h: u64, id: u32) -> Result<(), String> {- atspi::menu_invoke(h, id)- }- fn responsive(&self, h: u64, _timeout_ms: u32) -> Result<bool, String> {- // X has no WM_NULL: a window answers unless its process is stopped (x11 marks that `hung`).- Ok(x11::window_info(h)?.is_some_and(|w| !w.hung))- }- fn post_close(&self, h: u64) -> Result<(), String> {- x11::post_close(h)- }- fn foreground(&self) -> Result<u64, String> {- x11::foreground()- }- fn z_top(&self) -> Result<u64, String> {- x11::z_top()- }- fn push_to_background(&self, h: u64) -> Result<(), String> {- x11::push_to_background(h)- }- fn show_no_activate(&self, h: u64) -> Result<(), String> {- x11::show_no_activate(h)- }- fn bring_to_front(&self, h: u64) -> Result<bool, String> {- x11::activate(h, 800)- }- fn etiquette_set_enabled(&self, on: bool) {- GUARD_ENABLED.store(on, std::sync::atomic::Ordering::Relaxed)- }- fn etiquette_enabled(&self) -> bool {- GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed)- }- fn is_shell_window(&self, h: u64) -> bool {- h != 0 && x11::is_shell_window(h)- }- fn request_foreground(&self, h: u64) -> bool {- // 0 is "a window X cannot see" (a native Wayland app had focus): nothing to ask for.- h != 0 && x11::activate(h, 300).unwrap_or(false)- }- fn placement(&self, h: u64) -> Result<Placement, String> {- x11::placement(h)- }- fn set_placement(&self, h: u64, p: &Placement) -> Result<(), String> {- x11::set_placement(h, p)- }- fn on_a_monitor(&self, rect: (i32, i32, i32, i32)) -> Result<bool, String> {- x11::on_a_monitor(rect)- }- fn capture_window(&self, h: u64) -> Result<Capture, String> {- x11::capture_window(h)- }- fn gl_canvas_rect(&self, h: u64) -> Result<Option<(i32, i32, i32, i32)>, String> {- x11::gl_canvas_rect(h)- }- fn seconds_since_input(&self) -> Result<f64, String> {- x11::seconds_since_input()- }- // ---- Phase 3b: kicad_upgrade. No silent installer path here: KiCad on Linux comes // from the distro package, the PPA, snap or flatpak. The verb explains that; these four keep the facts honest. - fn elevation_info(&self) -> Result<Value, String> {- let mut cmd = Command::new("id");- cmd.arg("-u");- self.quiet_command(&mut cmd);- let euid: Option<u32> = cmd.output().ok().filter(|o| o.status.success()).and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());- let root = euid.map(|u| u == 0);- Ok(json!({- "platform": "linux",- "user": std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).unwrap_or_default(),- "euid": euid,- "isAdmin": root,- "uacEnabled": Value::Null,- "consentPrompt": Value::Null,- "canInstallAllUsersSilently": root,- "note": "no UAC on Linux; isAdmin is euid == 0. Package installs need sudo, which the bridge never runs",- }))- }- fn installer_kind(&self) -> &'static str {- "distro-package"- }- fn installer_download_url(&self, _version: &str) -> Option<String> {- None- }-- // ---- Freerouting: the linux-x64 zip (bundled Java). Size verified 2026-09-12.-- fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> {- Some(("https://github.com/freerouting/freerouting/releases/download/v2.4.1/freerouting-2.4.1-linux-x64.zip".into(), 91_927_019, "zip"))- }- fn freerouting_extract(&self, archive: &Path, dest: &Path, unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {- unzip(archive, dest)?;- // The zip carries unix modes the std-only reader does not restore: the launcher,- // and the runtime's helpers, need +x.- let root = std::fs::read_dir(dest)- .map_err(|e| e.to_string())?- .flatten()- .map(|e| e.path())- .find(|p| p.is_dir() && p.join("bin").join("freerouting").is_file())- .ok_or_else(|| format!("no <folder>/bin/freerouting under {} after unzip", dest.display()))?;- for rel in ["bin/freerouting", "lib/runtime/lib/jexec", "lib/runtime/lib/jspawnhelper"] {- let p = root.join(rel);- if p.is_file() {- chmod_x(&p)?;- }- }- if let Ok(rd) = std::fs::read_dir(root.join("lib").join("runtime").join("bin")) {- for e in rd.flatten() {- if e.path().is_file() {- chmod_x(&e.path())?;- }- }- }- Ok(root.join("bin").join("freerouting")) } } -/// systemd-run, when this user's systemd instance is up (its private socket exists).-fn user_systemd_run() -> Option<PathBuf> {- let rt = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty())?;- if !Path::new(&rt).join("systemd/private").exists() {- return None;- }- ["/usr/bin/systemd-run", "/bin/systemd-run"].iter().map(PathBuf::from).find(|p| p.is_file())-}- /// Field `n` after the `(comm)` of /proc/<pid>/stat: 0 = state, 1 = ppid. fn stat_field(stat: &str, n: usize) -> Option<String> { stat.get(stat.rfind(')')? + 1..)?.split_whitespace().nth(n).map(str::to_string)-}--fn native_cli_present() -> bool {- ["/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli", "/snap/bin/kicad-cli"].iter().any(|p| Path::new(p).is_file())-}--/// KiCad from Flathub (`org.kicad.KiCad`), system or per-user.-pub mod flatpak {- use std::path::{Path, PathBuf};-- pub const APP_ID: &str = "org.kicad.KiCad";-- fn home() -> Option<PathBuf> {- std::env::var("HOME").ok().map(PathBuf::from)- }-- /// "flatpak (user)" or "flatpak (system)" when the app is deployed; user wins, as flatpak run does.- pub fn installation() -> Option<&'static str> {- if home().is_some_and(|h| h.join(".local/share/flatpak/app").join(APP_ID).join("current").exists()) {- return Some("flatpak (user)");- }- if Path::new("/var/lib/flatpak/app").join(APP_ID).join("current").exists() {- return Some("flatpak (system)");- }- None- }-- /// Where the bridge keeps its launchers: $ADOM_BRIDGE_DATA_DIR/bin, else ~/.local/share/adom-kicad-bridge/bin.- pub fn wrapper_dir() -> Option<PathBuf> {- match std::env::var("ADOM_BRIDGE_DATA_DIR") {- Ok(d) if !d.is_empty() => Some(PathBuf::from(d).join("bin")),- _ => Some(home()?.join(".local/share/adom-kicad-bridge/bin")),- }- }-- /// A script that runs the KiCad Flatpak (someone's own ~/.local/bin wrapper, say).- pub fn is_flatpak_wrapper(p: &Path) -> bool {- std::fs::read(p).ok().filter(|b| b.len() < 8192).map(|b| String::from_utf8_lossy(&b).contains(APP_ID)).unwrap_or(false)- }-- fn has_gpu() -> bool {- std::fs::read_dir("/dev/dri").map(|d| d.flatten().any(|e| e.file_name().to_string_lossy().starts_with("renderD"))).unwrap_or(false)- }-- /// The launcher text for one KiCad program. Software GL when there is no render node,- /// because the 3D viewer and the OpenGL canvas fail outright without one.- pub fn wrapper_script(command: &str, gpu: bool) -> String {- let gl = if gpu { String::new() } else { " --env=LIBGL_ALWAYS_SOFTWARE=1 --env=GALLIUM_DRIVER=llvmpipe".to_string() };- let run = format!("flatpak run --no-documents-portal --filesystem=host --filesystem=/tmp --command={command}{gl} {APP_ID} \"$@\"");- let head = format!("#!/bin/sh\n# Written by the Adom KiCad Bridge (wiki #127): KiCad's {command} from the Flatpak, with the host's\n# files and /tmp shared so the paths the bridge passes resolve inside the sandbox.\n");- if command == "kicad-cli" {- return format!("{head}exec {run}\n");- }- // The GUI needs a D-Bus session bus: GTK's sandboxed image loader (glycin) aborts the- // editor at startup without one, and a bridge started by a service has none.- format!("{head}if [ -z \"$DBUS_SESSION_BUS_ADDRESS\" ] && command -v dbus-run-session >/dev/null 2>&1; then\n exec dbus-run-session -- {run}\nfi\nexec {run}\n")- }-- /// Write (or refresh) the launchers when the Flatpak is installed. Returns the installation and the dir.- pub fn ensure_wrappers() -> Option<(&'static str, PathBuf)> {- use std::os::unix::fs::PermissionsExt;- let installation = installation()?;- let dir = wrapper_dir()?;- std::fs::create_dir_all(&dir).ok()?;- let gpu = has_gpu();- for command in ["kicad-cli", "kicad", "pcbnew", "eeschema"] {- let p = dir.join(command);- let text = wrapper_script(command, gpu);- if std::fs::read_to_string(&p).ok().as_deref() != Some(text.as_str()) {- std::fs::write(&p, &text).ok()?;- }- let mut perm = std::fs::metadata(&p).ok()?.permissions();- if perm.mode() & 0o111 != 0o111 {- perm.set_mode(0o755);- std::fs::set_permissions(&p, perm).ok()?;- }- }- Some((installation, dir))- }-- #[cfg(test)]- mod tests {- #[test]- fn wrapper_shares_tmp_and_host() {- let s = super::wrapper_script("kicad-cli", false);- assert!(s.starts_with("#!/bin/sh\n"));- assert!(s.contains("--filesystem=/tmp") && s.contains("--filesystem=host") && s.contains("--command=kicad-cli"));- assert!(s.contains("LIBGL_ALWAYS_SOFTWARE=1"));- assert!(!super::wrapper_script("pcbnew", true).contains("LIBGL"));- assert!(super::wrapper_script("pcbnew", true).contains("dbus-run-session --"));- assert!(!s.contains("dbus-run-session"));- }- }-}--fn chmod_x(p: &Path) -> Result<(), String> {- use std::os::unix::fs::PermissionsExt;- let mut perm = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.permissions();- perm.set_mode(perm.mode() | 0o755);- std::fs::set_permissions(p, perm).map_err(|e| format!("chmod +x {}: {e}", p.display()))-}+-- /dev/null+-- /dev/null
rust/crates/kicad-platform/src/macos.rs−39@@ -1,112 +1,6 @@-//! macOS implementation (owner: Kyle). Headless parts are complete: the app bundle's-//! kicad-cli, ~/Library/Preferences/kicad/<ver>, ~/Documents/KiCad/<ver>. Window control-//! on macOS is `open -a`, osascript and the Accessibility API; add them here in phase 3-//! and flip the capability flags.--use std::path::{Path, PathBuf};-use std::process::Command;--use serde_json::{json, Value};--use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};--pub struct Native;--fn home() -> Option<PathBuf> {- std::env::var("HOME").ok().map(PathBuf::from)-}--impl Platform for Native {- fn capabilities(&self) -> Capabilities {- Capabilities {- os: "macos",- owner: "Kyle (macOS Hydrogen and Adom Bridge)",- kicad_cli: true,- window_control: false,- ui_automation: false,- dialog_sweep: false, focus_etiquette: false, ipc_api: false, silent_install: false,- session: "", } }- fn cli_file_name(&self) -> &'static str {- "kicad-cli"- }- fn find_installs(&self) -> Vec<KicadInstall> {- let mut out = Vec::new();- let mut candidates = vec![PathBuf::from("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli")];- if let Some(h) = home() {- candidates.push(h.join("Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"));- }- for (i, c) in candidates.iter().enumerate() {- if let Some(inst) = install_from_cli(c, "", "app bundle", i == 1) {