diff --git a/Cargo.lock b/Cargo.lock index 020f481f..d2b6be18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -50,6 +59,27 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.7", + "stable_deref_trait", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -161,6 +191,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + [[package]] name = "bit-set" version = "0.8.0" @@ -194,7 +230,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -380,6 +416,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -420,7 +462,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -587,7 +629,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" dependencies = [ - "heapless", + "heapless 0.8.0", "serde", ] @@ -779,6 +821,24 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -789,6 +849,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-types" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" +dependencies = [ + "approx", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.13.0", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", + "thiserror 2.0.19", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1834,6 +1912,24 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1889,13 +1985,38 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.7", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -2363,6 +2484,7 @@ dependencies = [ "anyhow", "axum", "base64 0.23.1", + "dirs", "konnect-ipc", "konnect-render", "konnect-schematic-editor", @@ -2378,6 +2500,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "topola_specctra", "tracing", "usvg", "uuid", @@ -2478,6 +2601,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2655,6 +2784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2698,6 +2828,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3233,6 +3369,79 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + [[package]] name = "rusqlite" version = "0.40.2" @@ -3654,6 +3863,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -3974,6 +4192,31 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "topola_specctra" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55f4109539b73c1cbed42da864d9aa6b092c832c4583866f8ae05c14a0c1d8c7" +dependencies = [ + "bimap", + "geo-types", + "serde", + "thiserror 2.0.19", + "topola_specctra_derive", + "utf8-chars", +] + +[[package]] +name = "topola_specctra_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca8cac44a7b0f33ab1140a973ca748d05f052bb306b93a91c00bf5ef05ac860" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tower" version = "0.5.3" @@ -4219,6 +4462,15 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-chars" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" +dependencies = [ + "arrayvec", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 55ef7791..e779209e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ axum = { version = "0.8", features = ["macros"] } # S-expression parsing nom = "8" +# Specctra DSN/SES syntax. Konnect owns the KiCad lowering and manifest; +# Topola supplies the format AST and serializer/parser used for parity checks. +topola_specctra = "0.1.0" + # NNG for KiCAD 10 IPC (nanomsg-next-gen, replaces ZeroMQ) nng = "1" prost = "0.13" diff --git a/DEV.md b/DEV.md index 1d5c100b..4f7015b9 100644 --- a/DEV.md +++ b/DEV.md @@ -94,13 +94,13 @@ Konnect/ │ │ ├── pcb_board.rs # 11 tools (S-expr file editing, IPC fallback, SVG logo import) │ │ ├── pcb_components.rs # 19 tools (IPC real-time + safe headless single-placement fallback) │ │ ├── pcb_footprint_update.rs # library refresh planner + one-commit IPC apply -│ │ ├── pcb_routing.rs # 12 tools (traces, vias, nets, netclasses) -│ │ ├── pcb_export.rs # 13 tools (Gerber, PDF, 3D, DRC, DXF/GenCAD/IPC-2581/ODB++) +│ │ ├── pcb_routing.rs # 15 tools (traces, vias, nets, netclasses, SES import) +│ │ ├── pcb_export.rs # 14 tools (Gerber, PDF, 3D, Specctra DSN, DRC, DXF/GenCAD/IPC-2581/ODB++) │ │ ├── library.rs # 17 tools (symbol/footprint library management) │ │ ├── footprint_graphics.rs # footprint primitive validation, inspection, and atomic edits │ │ ├── footprint_metadata.rs # footprint description, tags, and attribute edits │ │ ├── footprint_models.rs # footprint 3D model validation and atomic edits -│ │ ├── integration.rs # 8 tools (JLCPCB SQLite, Freerouting discovery, datasheets) +│ │ ├── integration.rs # 9 tools (JLCPCB SQLite, Freerouting MCP, datasheets) │ │ ├── verification.rs # 10 tools (DRC, design rules, KiCAD UI) │ │ ├── config.rs # 7 tools (user/project config, design rules) │ │ ├── design_review.rs # 6 tools (decoupling/connection/power/DFM audits) @@ -146,6 +146,7 @@ Konnect/ ├── plugin/ # Python thin launcher (runs inside KiCAD) │ ├── __init__.py # pcbnew.ActionPlugin — settings dialog (PCB Editor only) │ ├── settings_dialog.py # wxPython settings UI (paths, server control) +│ ├── native_bridge.py # authenticated KiCad 10 native Specctra export bridge │ └── plugin.json # KiCAD 10 IPC plugin manifest │ ├── packaging/ @@ -220,6 +221,11 @@ transaction abandon` escape hatch documented in - **PCM zip** is the correct install method - KiCAD installs to: `C:\Users\\Documents\KiCad\10.0\3rdparty\plugins\com_github_mixelpixx_konnect\` - Both `__init__.py` (SWIG ActionPlugin for PCB editor settings dialog) and `plugin.json` (IPC exec plugin) are included +- `native_bridge.py` is a KiCAD-10-only, opt-in compatibility bridge. It exposes + only authenticated status and native Specctra export over an ephemeral + loopback port. The caller cannot choose an output path; the plugin owns and + removes the temporary artifact. Do not grow it into a general Python RPC + surface or use it as the KiCAD 11 architecture. ## Structured Errors @@ -303,7 +309,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 217 tools (223 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 221 tools (227 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 6 meta-tools, baseline `tools/list` is 20 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -378,11 +384,13 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **20 toolsets, 217 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) +- **20 toolsets, 221 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) - Baseline `tools/list`: 20 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 223 tools (217 registered + 6 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 227 tools (221 registered + 6 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** -- **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. Konnect - therefore does not advertise autorouting until it has a real editor bridge; the - `check_freerouting` diagnostic still discovers PCM installations and Java. +- **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. + `export_specctra_dsn` creates a revision-bound routing job from the live + editor, `route_specctra_dsn` delegates the route to Freerouting's local native + MCP server, and `plan_specctra_ses_import` / `apply_specctra_ses` validate and + return the result through one KiCad undo transaction. diff --git a/README.md b/README.md index 14b146cf..982a00dd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**217 tools across 20 on-demand toolsets.** Schematic capture, PCB layout and +**221 tools across 20 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 217 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 221 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its @@ -106,6 +106,7 @@ The full tool catalog is documented in [tool-directory.md](tool-directory.md). |-------|-----------| | Schematic editing | Direct `.kicad_sch` S-expression editing with atomic writes (no KiCAD required) | | PCB editing | KiCAD 10 IPC API (NNG + protobuf) — real-time and undo-aware; single-footprint placement has a safe headless fallback | +| Specctra export | Revision-bound Rust export, with an optional authenticated KiCAD 10 ActionPlugin bridge for KiCAD-native DSN output | | Exports & checks | `kicad-cli` subprocess (Gerber, PDF, ERC, DRC, …) | | Transport | MCP JSON-RPC over stdio (default), or Streamable HTTP (`transport = "http"` / `"both"`) | @@ -126,6 +127,14 @@ The full tool catalog is documented in [tool-directory.md](tool-directory.md). Verify: open the **PCB Editor** → **Tools → External Plugins** → you should see **Konnect**. +For KiCAD 10, the Konnect settings dialog also offers an optional **native +Specctra bridge**. When enabled, `export_specctra_dsn` can ask the active PCB +Editor to generate its native DSN while Konnect still binds the export to the +exact IPC snapshot and creates the strict reverse manifest used during SES +import. The bridge is local-only, authenticated, disabled by default, and not +the KiCAD 11 integration path. If it is disabled or unavailable, the default +`prefer` policy falls back to Konnect's Rust exporter. + ### Build from source ```bash diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index b7275abe..3f9e7dab 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -5,6 +5,14 @@ projects. Konnect itself is licensed under the AGPL-3.0 (see LICENSE) with a commercial option (see COMMERCIAL.md); the notices below travel with every distribution of Konnect, under either license. +## topola_specctra + +Konnect uses the `topola_specctra` parser, data model, and serializer from the +[Topola project](https://codeberg.org/topola/topola) to validate and emit +Specctra DSN syntax. Copyright 2024 Topola contributors; used under the MIT +license. The MIT permission grant and warranty disclaimer reproduced below +apply to this dependency. + ## kicad-mcp-pro Portions of Konnect — including the JLCPCB pick-and-place rotation diff --git a/crates/konnect-core/Cargo.toml b/crates/konnect-core/Cargo.toml index 3487a2d1..ce043054 100644 --- a/crates/konnect-core/Cargo.toml +++ b/crates/konnect-core/Cargo.toml @@ -11,6 +11,7 @@ konnect-sexp.workspace = true konnect-render.workspace = true konnect-ipc.workspace = true konnect-schematic-editor.workspace = true +topola_specctra.workspace = true prost-types.workspace = true prost.workspace = true @@ -25,6 +26,7 @@ uuid.workspace = true base64.workspace = true sha2.workspace = true reqwest.workspace = true +dirs.workspace = true rusqlite.workspace = true usvg.workspace = true zip.workspace = true diff --git a/crates/konnect-core/src/freerouting_mcp.rs b/crates/konnect-core/src/freerouting_mcp.rs new file mode 100644 index 00000000..c64935a5 --- /dev/null +++ b/crates/konnect-core/src/freerouting_mcp.rs @@ -0,0 +1,770 @@ +//! Local Freerouting MCP orchestration. +//! +//! Konnect deliberately does not reimplement the router. It starts the local +//! Freerouting JAR in its documented headless stdio MCP mode, follows the +//! server's routing state machine, and returns only compact job evidence. + +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::task::JoinHandle; +use tokio::time::{timeout, Instant}; + +const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_STDERR_BYTES: usize = 64 * 1024; +const MAX_SES_BYTES: u64 = 512 * 1024 * 1024; + +const REQUIRED_TOOLS: &[&str] = &[ + "create_session", + "enqueue_job", + "upload_job_input_from_local_file", + "start_job", + "get_job_details", + "download_job_output_to_local_file", +]; + +#[derive(Debug, Clone)] +pub(crate) struct RouteSettings { + pub max_passes: Option, + pub optimizer_enabled: Option, + pub job_timeout_seconds: Option, + pub poll_interval: Duration, + pub overall_timeout: Duration, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct RouteEvidence { + pub session_id: String, + pub job_id: String, + pub final_state: String, + pub poll_count: u32, + pub elapsed_seconds: f64, + pub ses_bytes: u64, + pub server_protocol_version: String, + pub diagnostics_path: Option, +} + +pub(crate) async fn route_local( + jar: &Path, + dsn: &Path, + ses_output: &Path, + settings: &RouteSettings, +) -> Result { + validate_inputs(jar, dsn, ses_output, settings)?; + if let Some(parent) = ses_output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("create SES output directory {}", parent.display()))?; + } + let temporary = temporary_output_path(ses_output)?; + if temporary.exists() { + bail!( + "temporary SES output already exists: {}", + temporary.display() + ); + } + + let mut client = LocalMcpClient::start(jar).await?; + let started = Instant::now(); + let route_result = timeout( + settings.overall_timeout, + run_state_machine(&mut client, dsn, &temporary, settings, started), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "Freerouting MCP job exceeded the overall timeout of {} seconds", + settings.overall_timeout.as_secs() + ) + })?; + let diagnostics = client.close().await; + let diagnostics_path = write_diagnostics(ses_output, &diagnostics).await?; + + let result = match route_result { + Ok(evidence) => Ok(evidence), + Err(error) => { + if diagnostics.is_empty() { + Err(error) + } else { + Err(error).context(format!("Freerouting stderr: {diagnostics}")) + } + } + }; + let evidence = match result { + Ok(evidence) => evidence, + Err(error) => { + return Err(error); + } + }; + + let metadata = tokio::fs::metadata(&temporary) + .await + .with_context(|| format!("Freerouting did not create {}", temporary.display()))?; + if metadata.len() == 0 || metadata.len() > MAX_SES_BYTES { + let _ = tokio::fs::remove_file(&temporary).await; + bail!( + "Freerouting SES size {} is outside the supported range 1..={MAX_SES_BYTES}", + metadata.len() + ); + } + let ses_source = tokio::fs::read_to_string(&temporary) + .await + .with_context(|| format!("read Freerouting SES {}", temporary.display()))?; + let tree = konnect_sexp::parse_sexp(&ses_source).context("parse Freerouting SES output")?; + if tree.head() != Some("session") { + let _ = tokio::fs::remove_file(&temporary).await; + bail!("Freerouting output is not a Specctra SES session"); + } + konnect_sexp::write_new_atomic(ses_output, &ses_source) + .with_context(|| format!("create SES output {}", ses_output.display()))?; + tokio::fs::remove_file(&temporary) + .await + .with_context(|| format!("remove temporary SES {}", temporary.display()))?; + Ok(RouteEvidence { + ses_bytes: metadata.len(), + diagnostics_path, + ..evidence + }) +} + +async fn write_diagnostics(output: &Path, diagnostics: &str) -> Result> { + if diagnostics.is_empty() { + return Ok(None); + } + let path = output.with_extension("freerouting.log"); + if path.exists() { + bail!( + "Freerouting diagnostics output already exists: {}", + path.display() + ); + } + konnect_sexp::write_new_atomic(&path, diagnostics) + .with_context(|| format!("create Freerouting diagnostics {}", path.display()))?; + Ok(Some(path.display().to_string())) +} + +fn validate_inputs( + jar: &Path, + dsn: &Path, + ses_output: &Path, + settings: &RouteSettings, +) -> Result<()> { + if !jar.is_file() { + bail!("Freerouting JAR does not exist: {}", jar.display()); + } + if !dsn.is_file() || !extension_is(dsn, "dsn") { + bail!("DSN input must be an existing .dsn file: {}", dsn.display()); + } + if !extension_is(ses_output, "ses") { + bail!("SES output must have the .ses extension"); + } + if ses_output.exists() { + bail!("SES output already exists: {}", ses_output.display()); + } + if let Some(max_passes) = settings.max_passes { + if !(1..=100).contains(&max_passes) { + bail!("max_passes must be between 1 and 100"); + } + } + if settings.poll_interval < Duration::from_secs(2) + || settings.poll_interval > Duration::from_secs(5) + { + bail!("poll interval must be between 2 and 5 seconds"); + } + if settings.overall_timeout < Duration::from_secs(10) + || settings.overall_timeout > Duration::from_secs(86_400) + { + bail!("overall timeout must be between 10 and 86400 seconds"); + } + if settings + .job_timeout_seconds + .is_some_and(|seconds| seconds == 0 || seconds > 86_400) + { + bail!("job_timeout_seconds must be between 1 and 86400"); + } + Ok(()) +} + +async fn run_state_machine( + client: &mut LocalMcpClient, + dsn: &Path, + temporary: &Path, + settings: &RouteSettings, + started: Instant, +) -> Result { + let tools = client.list_tools().await?; + validate_tool_contracts(&tools)?; + let missing = REQUIRED_TOOLS + .iter() + .filter(|name| !tools.contains_key(**name)) + .copied() + .collect::>(); + if !missing.is_empty() { + bail!( + "Freerouting MCP is missing required tool(s): {}", + missing.join(", ") + ); + } + + let session = client.call_tool("create_session", json!({})).await?; + let session_id = required_string(&session, &["sessionId", "session_id", "id"], "session id")?; + let job = client + .call_tool( + "enqueue_job", + json!({ "body": { "session_id": session_id } }), + ) + .await?; + let job_id = required_string(&job, &["jobId", "job_id", "id"], "job id")?; + client + .call_tool( + "upload_job_input_from_local_file", + json!({ "jobId": job_id, "filePath": absolute_utf8(dsn)? }), + ) + .await?; + + let mut job_settings = Map::new(); + if let Some(max_passes) = settings.max_passes { + job_settings.insert("maxPasses".to_string(), json!(max_passes)); + } + if let Some(enabled) = settings.optimizer_enabled { + job_settings.insert("optimizer".to_string(), json!({ "enabled": enabled })); + } + if let Some(seconds) = settings.job_timeout_seconds { + job_settings.insert( + "jobTimeoutString".to_string(), + Value::String(duration_string(seconds)), + ); + } + if !job_settings.is_empty() { + if !tools.contains_key("update_job_settings") { + bail!("Freerouting MCP does not expose update_job_settings"); + } + client + .call_tool( + "update_job_settings", + json!({ "path": { "jobId": job_id }, "body": job_settings }), + ) + .await?; + } + client + .call_tool("start_job", json!({ "path": { "jobId": job_id } })) + .await?; + + let mut poll_count = 0u32; + let final_state = loop { + let details = client + .call_tool("get_job_details", json!({ "path": { "jobId": job_id } })) + .await?; + poll_count = poll_count.saturating_add(1); + let state = required_string(&details, &["state", "status"], "job state")?; + match state.to_ascii_uppercase().as_str() { + "COMPLETED" => break "COMPLETED".to_string(), + "FAILED" | "CANCELLED" | "CANCELED" => { + bail!("Freerouting job {job_id} ended in state {state}") + } + _ => tokio::time::sleep(settings.poll_interval).await, + } + }; + + client + .call_tool( + "download_job_output_to_local_file", + json!({ "jobId": job_id, "filePath": absolute_utf8(temporary)? }), + ) + .await?; + Ok(RouteEvidence { + session_id, + job_id, + final_state, + poll_count, + elapsed_seconds: started.elapsed().as_secs_f64(), + ses_bytes: 0, + server_protocol_version: client.server_protocol_version.clone(), + diagnostics_path: None, + }) +} + +fn validate_tool_contracts(tools: &BTreeMap) -> Result<()> { + for (tool, paths) in [ + ("enqueue_job", &[(&["body", "session_id"][..], false)][..]), + ( + "upload_job_input_from_local_file", + &[(&["jobId"][..], true), (&["filePath"][..], true)][..], + ), + ("start_job", &[(&["path", "jobId"][..], true)][..]), + ("get_job_details", &[(&["path", "jobId"][..], true)][..]), + ( + "download_job_output_to_local_file", + &[(&["jobId"][..], true), (&["filePath"][..], true)][..], + ), + ] { + let schema = tools + .get(tool) + .with_context(|| format!("Freerouting MCP is missing required tool '{tool}'"))?; + for (path, leaf_must_be_required) in paths { + require_schema_path(tool, schema, path, *leaf_must_be_required)?; + } + } + Ok(()) +} + +fn require_schema_path( + tool: &str, + schema: &Value, + path: &[&str], + leaf_must_be_required: bool, +) -> Result<()> { + let mut current = schema; + for (index, key) in path.iter().enumerate() { + if current.get("type").and_then(Value::as_str) != Some("object") { + bail!( + "Freerouting MCP tool '{tool}' has an incompatible input schema at '{}'", + path.join(".") + ); + } + let required = current + .get("required") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(key))); + if !required && (index + 1 < path.len() || leaf_must_be_required) { + bail!( + "Freerouting MCP tool '{tool}' does not require argument '{}'", + path.join(".") + ); + } + current = current + .get("properties") + .and_then(|properties| properties.get(*key)) + .with_context(|| { + format!( + "Freerouting MCP tool '{tool}' has no argument '{}'", + path.join(".") + ) + })?; + } + Ok(()) +} + +struct LocalMcpClient { + child: Child, + stdin: ChildStdin, + stdout: Lines>, + stderr_task: JoinHandle, + next_id: u64, + server_protocol_version: String, +} + +impl LocalMcpClient { + async fn start(jar: &Path) -> Result { + let mut command = Command::new("java"); + command + .arg("-jar") + .arg(jar) + .args([ + "--api_server.enabled=true", + "--api_server.authentication.enabled=false", + "--mcp_server.enabled=true", + "--mcp_server.authentication.enabled=false", + "--mcp_server.stdio=true", + "--gui.enabled=false", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command + .spawn() + .context("start local Freerouting MCP server")?; + let stdin = child.stdin.take().context("Freerouting MCP has no stdin")?; + let stdout = child + .stdout + .take() + .context("Freerouting MCP has no stdout")?; + let stderr = child + .stderr + .take() + .context("Freerouting MCP has no stderr")?; + let stderr_task = tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut bytes = Vec::new(); + let _ = reader + .take(MAX_STDERR_BYTES as u64) + .read_to_end(&mut bytes) + .await; + String::from_utf8_lossy(&bytes).trim().to_string() + }); + let mut client = Self { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + stderr_task, + next_id: 1, + server_protocol_version: String::new(), + }; + let initialize = timeout( + STARTUP_TIMEOUT, + client.request( + "initialize", + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "Konnect", "version": env!("CARGO_PKG_VERSION") } + }), + STARTUP_TIMEOUT, + ), + ) + .await + .map_err(|_| anyhow::anyhow!("Freerouting MCP initialization timed out"))??; + client.server_protocol_version = find_string(&initialize, &["protocolVersion"]) + .unwrap_or_else(|| MCP_PROTOCOL_VERSION.to_string()); + client + .notify("notifications/initialized", json!({})) + .await?; + Ok(client) + } + + async fn list_tools(&mut self) -> Result> { + let result = self + .request("tools/list", json!({}), REQUEST_TIMEOUT) + .await?; + let tools = result + .get("tools") + .and_then(Value::as_array) + .context("Freerouting tools/list returned no tools array")?; + tools + .iter() + .map(|tool| { + let name = tool + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .context("Freerouting tools/list contains an unnamed tool")?; + let schema = tool + .get("inputSchema") + .cloned() + .context("Freerouting tools/list contains a tool without inputSchema")?; + Ok((name, schema)) + }) + .collect() + } + + async fn call_tool(&mut self, name: &str, arguments: Value) -> Result { + let result = self + .request( + "tools/call", + json!({ "name": name, "arguments": arguments }), + REQUEST_TIMEOUT, + ) + .await + .with_context(|| format!("call Freerouting MCP tool '{name}'"))?; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + bail!( + "Freerouting MCP tool '{name}' returned an error: {}", + compact(&result) + ); + } + Ok(expand_text_content(result)) + } + + async fn request(&mut self, method: &str, params: Value, limit: Duration) -> Result { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + self.write_json(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) + .await?; + let deadline = Instant::now() + limit; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("Freerouting MCP request '{method}' timed out"); + } + let line = timeout(remaining, self.stdout.next_line()) + .await + .map_err(|_| anyhow::anyhow!("Freerouting MCP request '{method}' timed out"))?? + .context("Freerouting MCP closed stdout before responding")?; + if line.trim().is_empty() { + continue; + } + let response: Value = serde_json::from_str(&line) + .with_context(|| format!("Freerouting MCP emitted non-JSON stdout: {line}"))?; + if response.get("id") != Some(&json!(id)) { + if response.get("method").is_some() { + continue; + } + bail!("Freerouting MCP returned an unexpected response id"); + } + if let Some(error) = response.get("error") { + bail!( + "Freerouting MCP request '{method}' failed: {}", + compact(error) + ); + } + return response + .get("result") + .cloned() + .context("Freerouting MCP response has no result"); + } + } + + async fn notify(&mut self, method: &str, params: Value) -> Result<()> { + self.write_json(&json!({ "jsonrpc": "2.0", "method": method, "params": params })) + .await + } + + async fn write_json(&mut self, value: &Value) -> Result<()> { + let mut bytes = serde_json::to_vec(value)?; + bytes.push(b'\n'); + self.stdin + .write_all(&bytes) + .await + .context("write Freerouting MCP stdin")?; + self.stdin + .flush() + .await + .context("flush Freerouting MCP stdin") + } + + async fn close(mut self) -> String { + let _ = self.stdin.shutdown().await; + if timeout(SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() { + let _ = self.child.kill().await; + let _ = self.child.wait().await; + } + self.stderr_task.await.unwrap_or_default() + } +} + +fn expand_text_content(mut result: Value) -> Value { + let parsed = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .filter_map(|text| serde_json::from_str::(text).ok()) + .collect::>(); + if let Some(object) = result.as_object_mut() { + object.insert("parsedTextContent".to_string(), Value::Array(parsed)); + } + result +} + +fn required_string(value: &Value, keys: &[&str], label: &str) -> Result { + find_string(value, keys).with_context(|| { + format!( + "Freerouting MCP response has no {label}: {}", + compact(value) + ) + }) +} + +fn find_string(value: &Value, keys: &[&str]) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + { + if let Some(value) = value.as_str() { + return Some(value.to_string()); + } + } + } + object.values().find_map(|value| find_string(value, keys)) + } + Value::Array(values) => values.iter().find_map(|value| find_string(value, keys)), + _ => None, + } +} + +fn absolute_utf8(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + absolute + .to_str() + .map(str::to_string) + .with_context(|| format!("path is not UTF-8: {}", absolute.display())) +} + +fn temporary_output_path(output: &Path) -> Result { + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + let name = output + .file_name() + .and_then(|name| name.to_str()) + .context("SES output has no UTF-8 filename")?; + Ok(parent.join(format!(".{name}.konnect-{}.tmp.ses", uuid::Uuid::new_v4()))) +} + +fn duration_string(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let seconds = seconds % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} + +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn compact(value: &Value) -> String { + let text = value.to_string(); + if text.chars().count() > 2_000 { + let truncated: String = text.chars().take(2_000).collect(); + format!("{truncated}...") + } else { + text + } +} + +#[cfg(test)] +mod tests { + use super::*; + use konnect_ipc::{IpcEffectiveRoutingRules, IpcRoutingRules}; + + fn routing_rules() -> IpcEffectiveRoutingRules { + ["GND", "VCC"] + .into_iter() + .map(|net| { + ( + net.to_string(), + IpcRoutingRules { + class_name: "Default".to_string(), + constituents: vec!["Default".to_string()], + track_width_mm: Some(0.25), + clearance_mm: Some(0.2), + via_diameter_mm: Some(0.6), + via_drill_mm: Some(0.3), + }, + ) + }) + .collect() + } + + #[test] + fn nested_and_text_wrapped_identifiers_are_found() { + let value = expand_text_content(json!({ + "content": [{ "type": "text", "text": "{\"data\":{\"sessionId\":\"abc\"}}" }] + })); + assert_eq!(find_string(&value, &["sessionId"]), Some("abc".to_string())); + } + + #[test] + fn duration_settings_use_documented_shape() { + assert_eq!(duration_string(5 * 60), "00:05:00"); + assert_eq!(duration_string(25 * 60 * 60), "25:00:00"); + } + + #[test] + fn output_path_is_unique_and_stays_beside_destination() { + let output = Path::new("build/board.ses"); + let first = temporary_output_path(output).unwrap(); + let second = temporary_output_path(output).unwrap(); + assert_eq!(first.parent(), output.parent()); + assert_ne!(first, second); + assert!(extension_is(&first, "ses")); + } + + #[test] + fn required_tool_schema_drift_is_refused_before_routing() { + let object = |properties: Value, required: Value| json!({ "type": "object", "properties": properties, "required": required }); + let mut tools = BTreeMap::new(); + tools.insert("enqueue_job".into(), object(json!({"body": object(json!({"session_id": {"type":"string"}}), json!(["session_id"]))}), json!(["body"]))); + tools.insert( + "upload_job_input_from_local_file".into(), + object( + json!({"jobId":{"type":"string"},"filePath":{"type":"string"}}), + json!(["jobId", "filePath"]), + ), + ); + tools.insert( + "start_job".into(), + object( + json!({"path": object(json!({"jobId":{"type":"string"}}), json!(["jobId"]))}), + json!(["path"]), + ), + ); + tools.insert("get_job_details".into(), tools["start_job"].clone()); + tools.insert( + "download_job_output_to_local_file".into(), + tools["upload_job_input_from_local_file"].clone(), + ); + validate_tool_contracts(&tools).unwrap(); + + tools.get_mut("start_job").unwrap()["properties"]["path"]["required"] = json!([]); + let error = validate_tool_contracts(&tools).unwrap_err().to_string(); + assert!( + error.contains("start_job") && error.contains("path.jobId"), + "{error}" + ); + } + + #[tokio::test] + async fn stderr_diagnostics_are_preserved_beside_the_requested_output() { + let temp = tempfile::tempdir().unwrap(); + let output = temp.path().join("board.ses"); + let path = write_diagnostics(&output, "router failed at pass 3") + .await + .unwrap() + .unwrap(); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "router failed at pass 3" + ); + } + + /// Optional local parity check for Freerouting's actual stdio MCP state + /// machine. CI does not install Java or Freerouting. + #[tokio::test] + #[ignore = "requires Java and FREEROUTING_JAR"] + async fn native_mcp_routes_the_export_fixture() { + let jar = PathBuf::from(std::env::var_os("FREEROUTING_JAR").expect("set FREEROUTING_JAR")); + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let temp = tempfile::tempdir().unwrap(); + let board = temp.path().join("board.kicad_pcb"); + let dsn = temp.path().join("board.dsn"); + let ses = temp.path().join("board.ses"); + std::fs::write(&board, source).unwrap(); + let export = crate::specctra::export_dsn(&board, source, &routing_rules()).unwrap(); + std::fs::write(&dsn, export.dsn).unwrap(); + + let evidence = route_local( + &jar, + &dsn, + &ses, + &RouteSettings { + max_passes: Some(2), + optimizer_enabled: Some(false), + job_timeout_seconds: Some(300), + poll_interval: Duration::from_secs(2), + overall_timeout: Duration::from_secs(300), + }, + ) + .await + .unwrap(); + assert_eq!(evidence.final_state, "COMPLETED"); + assert!(evidence.ses_bytes > 0); + assert!(ses.is_file()); + } +} diff --git a/crates/konnect-core/src/lib.rs b/crates/konnect-core/src/lib.rs index c566d56d..5a6877a4 100644 --- a/crates/konnect-core/src/lib.rs +++ b/crates/konnect-core/src/lib.rs @@ -1,7 +1,11 @@ pub mod design_hash; +pub(crate) mod freerouting_mcp; pub mod gates; pub mod kicad_install; pub mod mcp; +pub(crate) mod native_specctra_bridge; pub mod observability; pub mod router; +pub(crate) mod specctra; +pub(crate) mod specctra_ses; pub mod tools; diff --git a/crates/konnect-core/src/native_specctra_bridge.rs b/crates/konnect-core/src/native_specctra_bridge.rs new file mode 100644 index 00000000..14e689f5 --- /dev/null +++ b/crates/konnect-core/src/native_specctra_bridge.rs @@ -0,0 +1,397 @@ +//! Optional KiCad 10 native Specctra export through Konnect's legacy ActionPlugin. +//! +//! The bridge is deliberately capability-scoped. It may export the active +//! board into its own private temporary directory; it cannot execute arbitrary +//! Python or write a caller-selected path. KiCad 11 and installations without +//! the legacy plugin continue through Konnect's Rust exporter. + +use anyhow::{bail, Context, Result}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const PROTOCOL_VERSION: u32 = 1; +const MAX_DSN_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct NativeExport { + pub dsn: String, + pub plugin_pid: u32, + pub protocol_version: u32, +} + +#[derive(Debug, Default)] +pub(crate) struct NativeExportAttempt { + pub export: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Registration { + protocol_version: u32, + pid: u32, + address: String, + token: String, + started_at_unix: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StatusResponse { + success: bool, + protocol_version: u32, + pid: u32, + native_specctra_export: bool, +} + +#[derive(Debug, Serialize)] +struct ExportRequest<'a> { + expected_board: &'a str, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExportResponse { + success: bool, + protocol_version: u32, + pid: u32, + board_path: String, + dsn_path: String, + dsn_bytes: u64, +} + +pub(crate) async fn try_export(board: &Path) -> NativeExportAttempt { + let mut attempt = NativeExportAttempt::default(); + let Some(root) = registration_root() else { + attempt + .diagnostics + .push("native bridge registration directory is unavailable".to_string()); + return attempt; + }; + let entries = match registrations(&root) { + Ok(entries) => entries, + Err(error) => { + attempt + .diagnostics + .push(format!("native bridge discovery failed: {error:#}")); + return attempt; + } + }; + if entries.is_empty() { + attempt + .diagnostics + .push("no enabled KiCad 10 native bridge registration was found".to_string()); + return attempt; + } + let board = match board.canonicalize() { + Ok(board) => board, + Err(error) => { + attempt + .diagnostics + .push(format!("resolve requested board failed: {error}")); + return attempt; + } + }; + for registration_path in entries { + match try_registration(&root, ®istration_path, &board).await { + Ok(export) => { + attempt.export = Some(export); + return attempt; + } + Err(error) => attempt.diagnostics.push(format!( + "{}: {error:#}", + registration_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("native bridge registration") + )), + } + } + attempt +} + +fn registration_root() -> Option { + if let Some(path) = std::env::var_os("KONNECT_BRIDGE_DIR") { + return Some(PathBuf::from(path)); + } + dirs::data_local_dir().map(|path| path.join("konnect").join("native-bridge")) +} + +fn registrations(root: &Path) -> Result> { + if !root.is_dir() { + return Ok(Vec::new()); + } + let mut entries = std::fs::read_dir(root) + .with_context(|| format!("read native bridge directory {}", root.display()))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("bridge-") && name.ends_with(".json")) + }) + .collect::>(); + entries.sort(); + Ok(entries) +} + +async fn try_registration( + root: &Path, + registration_path: &Path, + board: &Path, +) -> Result { + let registration_source = std::fs::read_to_string(registration_path) + .with_context(|| format!("read {}", registration_path.display()))?; + let registration: Registration = + serde_json::from_str(®istration_source).context("parse bridge registration")?; + validate_registration(®istration)?; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(35)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("build native bridge HTTP client")?; + let status: StatusResponse = client + .get(format!("{}/v1/status", registration.address)) + .bearer_auth(®istration.token) + .send() + .await + .context("query native bridge status")? + .error_for_status() + .context("native bridge status refused")? + .json() + .await + .context("parse native bridge status")?; + if !status.success + || !status.native_specctra_export + || status.protocol_version != PROTOCOL_VERSION + || status.pid != registration.pid + { + bail!("native bridge status does not match its registration"); + } + + let board_text = board + .to_str() + .with_context(|| format!("board path is not UTF-8: {}", board.display()))?; + let response: ExportResponse = client + .post(format!("{}/v1/export-specctra-dsn", registration.address)) + .bearer_auth(®istration.token) + .json(&ExportRequest { + expected_board: board_text, + }) + .send() + .await + .context("request native Specctra export")? + .error_for_status() + .context("native Specctra export refused")? + .json() + .await + .context("parse native Specctra export response")?; + validate_response(®istration, &response, board)?; + + let dsn_path = PathBuf::from(&response.dsn_path) + .canonicalize() + .with_context(|| format!("resolve native DSN {}", response.dsn_path))?; + let canonical_root = root + .canonicalize() + .with_context(|| format!("resolve bridge root {}", root.display()))?; + if !dsn_path.starts_with(&canonical_root) { + bail!("native bridge returned a DSN outside its private directory"); + } + let metadata = std::fs::metadata(&dsn_path) + .with_context(|| format!("inspect native DSN {}", dsn_path.display()))?; + if metadata.len() == 0 || metadata.len() > MAX_DSN_BYTES || metadata.len() != response.dsn_bytes + { + bail!("native bridge DSN size does not match its response"); + } + let dsn = std::fs::read_to_string(&dsn_path) + .with_context(|| format!("read native DSN {}", dsn_path.display())); + let cleanup = std::fs::remove_file(&dsn_path) + .with_context(|| format!("remove consumed native DSN {}", dsn_path.display())); + let dsn = dsn?; + cleanup?; + if !dsn.trim_start().starts_with("(pcb ") { + bail!("KiCad native export is not a Specctra PCB"); + } + Ok(NativeExport { + dsn, + plugin_pid: response.pid, + protocol_version: response.protocol_version, + }) +} + +fn validate_registration(registration: &Registration) -> Result<()> { + if registration.protocol_version != PROTOCOL_VERSION + || registration.pid == 0 + || registration.token.len() < 32 + || !registration.started_at_unix.is_finite() + || registration.started_at_unix <= 0.0 + { + bail!("native bridge registration is invalid or incompatible"); + } + let url = Url::parse(®istration.address).context("parse native bridge address")?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + bail!("native bridge address is not a plain IPv4 loopback endpoint"); + } + Ok(()) +} + +fn validate_response( + registration: &Registration, + response: &ExportResponse, + board: &Path, +) -> Result<()> { + if !response.success + || response.protocol_version != registration.protocol_version + || response.pid != registration.pid + || !Path::new(&response.dsn_path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("dsn")) + { + bail!("native bridge export response is invalid"); + } + let active_board = Path::new(&response.board_path) + .canonicalize() + .with_context(|| format!("resolve active bridge board {}", response.board_path))?; + let requested_board = board + .canonicalize() + .with_context(|| format!("resolve requested bridge board {}", board.display()))?; + if active_board != requested_board { + bail!( + "native bridge board '{}' does not match requested board '{}'", + active_board.display(), + requested_board.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn registration_requires_exact_loopback_shape() { + let valid = Registration { + protocol_version: 1, + pid: 42, + address: "http://127.0.0.1:32123".to_string(), + token: "x".repeat(32), + started_at_unix: 1.0, + }; + validate_registration(&valid).unwrap(); + for address in [ + "https://127.0.0.1:32123", + "http://localhost:32123", + "http://127.0.0.1:32123/path", + "http://example.com:32123", + ] { + let invalid = Registration { + address: address.to_string(), + ..Registration { + protocol_version: valid.protocol_version, + pid: valid.pid, + address: valid.address.clone(), + token: valid.token.clone(), + started_at_unix: valid.started_at_unix, + } + }; + assert!(validate_registration(&invalid).is_err(), "{address}"); + } + } + + #[test] + fn discovery_is_empty_for_a_missing_directory() { + let temp = tempfile::tempdir().unwrap(); + assert!(registrations(&temp.path().join("missing")) + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn authenticated_registration_returns_and_consumes_private_dsn() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("native-bridge"); + let session = root.join("session-42-test"); + std::fs::create_dir_all(&session).unwrap(); + let board = temp.path().join("board.kicad_pcb"); + std::fs::write(&board, "(kicad_pcb)\n").unwrap(); + let dsn = session.join("native-test.dsn"); + let dsn_source = "(pcb native-test)\n"; + std::fs::write(&dsn, dsn_source).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let token = "t".repeat(32); + let board_text = board.canonicalize().unwrap().display().to_string(); + let dsn_text = dsn.canonicalize().unwrap().display().to_string(); + let server = tokio::spawn(async move { + for response in [ + serde_json::json!({ + "success": true, + "protocol_version": 1, + "pid": 42, + "native_specctra_export": true + }), + serde_json::json!({ + "success": true, + "protocol_version": 1, + "pid": 42, + "board_path": board_text, + "dsn_path": dsn_text, + "dsn_bytes": dsn_source.len() + }), + ] { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 16 * 1024]; + let count = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..count]); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer tttttttttttttttttttttttttttttttt")); + let body = response.to_string(); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), body + ); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + let registration_path = root.join("bridge-42.json"); + std::fs::write( + ®istration_path, + serde_json::json!({ + "protocol_version": 1, + "pid": 42, + "address": format!("http://127.0.0.1:{port}"), + "token": token, + "started_at_unix": 1.0 + }) + .to_string(), + ) + .unwrap(); + + let export = try_registration(&root, ®istration_path, &board) + .await + .unwrap(); + server.await.unwrap(); + assert_eq!(export.dsn, dsn_source); + assert_eq!(export.plugin_pid, 42); + assert!( + !dsn.exists(), + "Rust must consume the private bridge artifact" + ); + } +} diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index f78e5104..3b911a16 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -80,9 +80,9 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ }, ToolsetMeta { name: "pcb_routing", - description: "Traces, vias, copper pours, net classes, differential pairs", + description: "Traces, vias, copper pours, net classes, differential pairs, and strict Specctra SES import", category: "pcb", - tool_count: 13, + tool_count: 15, }, ToolsetMeta { name: "placement", @@ -92,9 +92,9 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ }, ToolsetMeta { name: "pcb_export", - description: "Gerber, PDF, SVG, 3D model, BOM, pick-and-place, DRC, DXF/GenCAD/IPC-2581/ODB++", + description: "Gerber, PDF, SVG, 3D model, BOM, Specctra DSN, pick-and-place, DRC, DXF/GenCAD/IPC-2581/ODB++", category: "pcb", - tool_count: 13, + tool_count: 14, }, ToolsetMeta { name: "library", @@ -104,9 +104,9 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ }, ToolsetMeta { name: "integration", - description: "JLCPCB parts database, Freerouting installation discovery, datasheet URLs", + description: "JLCPCB parts database, local Freerouting MCP routing, datasheet URLs", category: "integration", - tool_count: 8, + tool_count: 9, }, ToolsetMeta { name: "verification", diff --git a/crates/konnect-core/src/specctra.rs b/crates/konnect-core/src/specctra.rs new file mode 100644 index 00000000..185ae08f --- /dev/null +++ b/crates/konnect-core/src/specctra.rs @@ -0,0 +1,1657 @@ +//! Fail-closed KiCad PCB snapshot to Specctra DSN lowering. +//! +//! The input is the exact string returned by KiCad's IPC +//! `SaveDocumentToString`; this module never reads or edits the live board +//! file. The first supported profile is intentionally narrow and rejects +//! every feature it cannot represent without approximation. + +use anyhow::{bail, Context, Result}; +use konnect_ipc::IpcEffectiveRoutingRules; +use konnect_sexp::{parse_sexp, SexpNode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use specctra::read::{ListTokenizer, ReadDsn}; +use specctra::structure as dsn; +use specctra::write::{ListWriter, WriteSes}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufReader, Cursor}; +use std::path::Path; + +const UM_PER_MM: f64 = 1_000.0; +const DSN_RESOLUTION: f32 = 10.0; + +#[derive(Debug, Clone)] +pub(crate) struct ExportBundle { + pub dsn: String, + pub manifest: String, + pub source_sha256: String, + pub component_count: usize, + pub pad_count: usize, + pub net_count: usize, + pub class_count: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum PadShape { + Circle, + Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PadstackKey { + shape: PadShape, + layers: Vec, + size_x_um: i64, + size_y_um: i64, + drill_um: Option, +} + +#[derive(Debug, Clone)] +struct PadModel { + number: String, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + net: Option, + padstack: PadstackKey, +} + +#[derive(Debug, Clone)] +struct FootprintModel { + reference: String, + kiid: String, + image_name: String, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + pads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct RuleKey { + track_width_um: i64, + clearance_um: i64, + via_diameter_um: i64, + via_drill_um: i64, +} + +type RuleGroups = BTreeMap>; +type NetClassNames = BTreeMap; + +#[derive(Debug, Deserialize, Serialize)] +struct Manifest { + schema_version: u32, + board_path: String, + source_sha256: String, + coordinate_unit: String, + resolution: u32, + supported_profile: SupportedProfile, + layers: Vec, + components: Vec, + nets: Vec, + padstacks: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct SupportedProfile { + copper_layers: u32, + component_side: String, + pad_shapes: Vec, + existing_routing: bool, + copper_zones: bool, + custom_rules: bool, + outline: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestLayer { + kicad_name: String, + dsn_name: String, + index: usize, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestComponent { + reference: String, + kiid: String, + image_name: String, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + side: String, + pads: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestPin { + pad_number: String, + dsn_pin: String, + net: Option, + padstack_name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestNet { + name: String, + pins: Vec, + class_name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestPadstack { + name: String, + purpose: String, + shape: PadShape, + layers: Vec, + size_x_um: i64, + size_y_um: i64, + drill_um: Option, +} + +pub(crate) fn export_dsn( + board_path: &Path, + board_source: &str, + effective_rules: &IpcEffectiveRoutingRules, +) -> Result { + let tree = parse_sexp(board_source).context("parse KiCad IPC board snapshot")?; + if tree.head() != Some("kicad_pcb") { + bail!("KiCad IPC snapshot root is not 'kicad_pcb'"); + } + + reject_unsupported_board_items(&tree)?; + let copper_layers = copper_layers(&tree)?; + let outline = simple_closed_outline(&tree)?; + let net_table = top_level_net_table(&tree); + let footprints = footprints(&tree, &net_table)?; + if footprints.is_empty() { + bail!("supported routing profile requires at least one footprint"); + } + + let mut net_pins: BTreeMap> = BTreeMap::new(); + for footprint in &footprints { + for pad in &footprint.pads { + if let Some(net) = &pad.net { + net_pins + .entry(net.clone()) + .or_default() + .push(format!("{}-{}", footprint.reference, pad.number)); + } + } + } + if net_pins.is_empty() { + bail!("supported routing profile requires at least one connected pad"); + } + for pins in net_pins.values_mut() { + pins.sort(); + pins.dedup(); + } + + let (class_nets, net_classes) = normalize_rules(&net_pins, effective_rules)?; + + let mut padstack_keys = BTreeSet::new(); + for footprint in &footprints { + for pad in &footprint.pads { + padstack_keys.insert(pad.padstack.clone()); + } + } + let padstack_names: BTreeMap = padstack_keys + .into_iter() + .enumerate() + .map(|(index, key)| (key, format!("konnect_pad_{:04}", index + 1))) + .collect(); + + let via_keys: BTreeSet = class_nets.keys().cloned().collect(); + let via_names: BTreeMap = via_keys + .into_iter() + .enumerate() + .map(|(index, key)| (key, format!("konnect_via_{:04}", index + 1))) + .collect(); + + let pcb = build_pcb( + board_path, + &copper_layers, + &outline, + &footprints, + &net_pins, + &class_nets, + &net_classes, + &padstack_names, + &via_names, + )?; + let dsn = serialize_and_validate(pcb)?; + let source_sha256 = sha256_hex(board_source.as_bytes()); + let manifest = build_manifest( + board_path, + &source_sha256, + &copper_layers, + &footprints, + &net_pins, + &net_classes, + &padstack_names, + &via_names, + )?; + + Ok(ExportBundle { + dsn, + manifest, + source_sha256, + component_count: footprints.len(), + pad_count: footprints + .iter() + .map(|footprint| footprint.pads.len()) + .sum(), + net_count: net_pins.len(), + class_count: class_nets.len(), + }) +} + +/// Replace Konnect's deterministic DSN with a KiCad-native DSN while keeping +/// the same fail-closed board profile and rewriting the reverse manifest to +/// the exact identifiers KiCad emitted. This lets the strict SES importer +/// remain authoritative instead of trusting a second, looser return path. +pub(crate) fn adopt_native_dsn( + mut baseline: ExportBundle, + native_dsn: String, +) -> Result { + validate_dsn_syntax(&native_dsn, "KiCad native DSN")?; + let baseline_tree = parse_dsn_sexp(&baseline.dsn).context("parse Konnect baseline DSN")?; + let native_tree = parse_dsn_sexp(&native_dsn).context("parse KiCad native DSN")?; + let baseline_identity = dsn_identity(&baseline_tree)?; + let native_identity = dsn_identity(&native_tree)?; + let mut manifest: Manifest = + serde_json::from_str(&baseline.manifest).context("parse baseline routing manifest")?; + + let layer_names = correlate_layers(&baseline_identity, &native_identity)?; + let (class_names, via_names) = correlate_classes(&baseline_identity, &native_identity)?; + let padstack_names = correlate_components(&baseline_identity, &native_identity)?; + validate_native_nets(&baseline_identity, &native_identity)?; + + for layer in &mut manifest.layers { + layer.dsn_name = layer_names + .get(&layer.dsn_name) + .with_context(|| format!("native DSN omitted layer '{}'", layer.dsn_name))? + .clone(); + } + for component in &mut manifest.components { + let native = native_identity + .components + .get(&component.reference) + .with_context(|| format!("native DSN omitted component '{}'", component.reference))?; + component.image_name = native.image_name.clone(); + for pin in &mut component.pads { + pin.padstack_name = padstack_names + .get(&pin.padstack_name) + .with_context(|| { + format!( + "native DSN omitted padstack mapping for {}-{}", + component.reference, pin.pad_number + ) + })? + .clone(); + } + } + for net in &mut manifest.nets { + net.class_name = class_names + .get(&net.class_name) + .with_context(|| format!("native DSN omitted class mapping for '{}'", net.name))? + .clone(); + } + for padstack in &mut manifest.padstacks { + let mapping = if padstack.purpose == "via" { + &via_names + } else { + &padstack_names + }; + padstack.name = mapping + .get(&padstack.name) + .with_context(|| format!("native DSN omitted padstack '{}'", padstack.name))? + .clone(); + } + manifest + .padstacks + .sort_by(|left, right| left.name.cmp(&right.name)); + baseline.manifest = + serde_json::to_string_pretty(&manifest).context("serialize native routing manifest")?; + baseline.dsn = native_dsn; + Ok(baseline) +} + +#[derive(Debug, Clone)] +struct DsnPlacementIdentity { + image_name: String, + x: i64, + y: i64, + side: String, + rotation: OrderedF64, + pins: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DsnPinIdentity { + padstack: String, + x: i64, + y: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct OrderedF64(u64); + +impl OrderedF64 { + fn new(value: f64) -> Result { + if !value.is_finite() { + bail!("DSN contains a non-finite number"); + } + Ok(Self(value.to_bits())) + } +} + +#[derive(Debug, Clone)] +struct DsnClassIdentity { + nets: BTreeSet, + via_name: String, + width: i64, + clearance: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DsnShapeIdentity { + Circle { + layer: String, + diameter: i64, + }, + Rect { + layer: String, + x1: i64, + y1: i64, + x2: i64, + y2: i64, + }, +} + +#[derive(Debug, Clone)] +struct DsnIdentity { + layers: BTreeMap, + components: BTreeMap, + nets: BTreeMap>, + classes: BTreeMap, + padstacks: BTreeMap>, +} + +fn validate_dsn_syntax(source: &str, label: &str) -> Result<()> { + let cursor = Cursor::new(source.as_bytes()); + let mut tokenizer = ListTokenizer::new(BufReader::new(cursor)); + dsn::DsnFile::read_dsn(&mut tokenizer) + .map_err(|error| anyhow::anyhow!("{label} failed Specctra parser validation: {error}"))?; + Ok(()) +} + +fn parse_dsn_sexp(source: &str) -> Result { + // Specctra represents the quote delimiter as the bare token + // `(string_quote ")`. KiCad's S-expression parser correctly treats the + // opening quote as a string delimiter, so give only this metadata token an + // escaped representation for structural inspection. The original DSN is + // retained byte-for-byte and independently validated by Topola's parser. + let compatible = source.replace("(string_quote \")", "(string_quote \"\\\"\")"); + parse_sexp(&compatible).context("parse normalized Specctra S-expression") +} + +fn dsn_identity(root: &SexpNode) -> Result { + if root.head() != Some("pcb") { + bail!("Specctra export root is not 'pcb'"); + } + let structure = root.find("structure").context("DSN has no structure")?; + let mut layers = BTreeMap::new(); + for layer in structure.find_all("layer") { + let name = dsn_atom(layer, 1, "layer name")?.to_string(); + let index = layer + .find("property") + .and_then(|property| property.find("index")) + .and_then(|index| index.get(1)) + .and_then(SexpNode::as_str) + .context("DSN layer has no index")? + .parse::() + .context("DSN layer index is not an integer")?; + if layers.insert(index, name).is_some() { + bail!("DSN repeats a layer index"); + } + } + + let library = root.find("library").context("DSN has no library")?; + let mut image_pins = BTreeMap::>::new(); + for image in library.find_all("image") { + let name = dsn_atom(image, 1, "image name")?.to_string(); + let mut pins = BTreeMap::new(); + for pin in image.find_all("pin") { + let padstack = dsn_atom(pin, 1, "pin padstack")?.to_string(); + let number = dsn_atom(pin, 2, "pin number")?.to_string(); + let identity = DsnPinIdentity { + padstack, + x: dsn_integer(pin, 3, "pin x")?, + y: dsn_integer(pin, 4, "pin y")?, + }; + if pins.insert(number, identity).is_some() { + bail!("DSN image '{name}' repeats a pin number"); + } + } + if image_pins.insert(name.clone(), pins).is_some() { + bail!("DSN repeats image '{name}'"); + } + } + let mut padstacks = BTreeMap::new(); + for padstack in library.find_all("padstack") { + let name = dsn_atom(padstack, 1, "padstack name")?.to_string(); + let shapes = padstack + .find_all("shape") + .into_iter() + .map(|shape| { + let geometry = shape.get(1).context("DSN shape has no geometry")?; + match geometry.head() { + Some("circle") => Ok(DsnShapeIdentity::Circle { + layer: dsn_atom(geometry, 1, "circle layer")?.to_string(), + diameter: dsn_integer(geometry, 2, "circle diameter")?, + }), + Some("rect") => Ok(DsnShapeIdentity::Rect { + layer: dsn_atom(geometry, 1, "rect layer")?.to_string(), + x1: dsn_integer(geometry, 2, "rect x1")?, + y1: dsn_integer(geometry, 3, "rect y1")?, + x2: dsn_integer(geometry, 4, "rect x2")?, + y2: dsn_integer(geometry, 5, "rect y2")?, + }), + other => bail!("DSN padstack has unsupported shape {other:?}"), + } + }) + .collect::>>()?; + if shapes.is_empty() || padstacks.insert(name.clone(), shapes).is_some() { + bail!("DSN contains an empty or duplicate padstack '{name}'"); + } + } + + let placement = root.find("placement").context("DSN has no placement")?; + let mut components = BTreeMap::new(); + for component in placement.find_all("component") { + let image_name = dsn_atom(component, 1, "component image")?.to_string(); + let pins = image_pins + .get(&image_name) + .with_context(|| format!("DSN placement uses unknown image '{image_name}'"))? + .clone(); + for place in component.find_all("place") { + let reference = dsn_atom(place, 1, "place reference")?.to_string(); + let x = dsn_integer(place, 2, "place x")?; + let y = dsn_integer(place, 3, "place y")?; + let side = dsn_atom(place, 4, "place side")?.to_string(); + let rotation = OrderedF64::new(dsn_number(place, 5, "place rotation")?)?; + let identity = DsnPlacementIdentity { + image_name: image_name.clone(), + x, + y, + side, + rotation, + pins: pins.clone(), + }; + if components.insert(reference.clone(), identity).is_some() { + bail!("DSN repeats component '{reference}'"); + } + } + } + + let network = root.find("network").context("DSN has no network")?; + let mut nets = BTreeMap::new(); + for net in network.find_all("net") { + let name = dsn_atom(net, 1, "net name")?.to_string(); + let pins = net + .find("pins") + .context("DSN net has no pins")? + .children() + .context("DSN pins is not a list")? + .iter() + .skip(1) + .map(|pin| { + pin.as_str() + .map(str::to_string) + .context("DSN pins contains a list") + }) + .collect::>>()?; + if pins.is_empty() || nets.insert(name.clone(), pins).is_some() { + bail!("DSN contains an empty or duplicate net '{name}'"); + } + } + let mut classes = BTreeMap::new(); + for class in network.find_all("class") { + let name = dsn_atom(class, 1, "class name")?.to_string(); + let children = class.children().context("DSN class is not a list")?; + let nets = children + .iter() + .skip(2) + .take_while(|child| child.as_str().is_some()) + .map(|child| child.as_str().unwrap().to_string()) + .collect::>(); + let via_name = class + .find("circuit") + .and_then(|circuit| circuit.find("use_via")) + .and_then(|via| via.get(1)) + .and_then(SexpNode::as_str) + .context("DSN class has no use_via")? + .to_string(); + let rule = class.find("rule").context("DSN class has no rule")?; + let width = rule.find("width").context("DSN class has no width")?; + let clearance = rule + .find("clearance") + .context("DSN class has no clearance")?; + let width = dsn_integer(width, 1, "class width")?; + let clearance = dsn_integer(clearance, 1, "class clearance")?; + if nets.is_empty() + || !nets.iter().all(|net| { + network + .find_all("net") + .iter() + .any(|node| node.get(1).and_then(SexpNode::as_str) == Some(net)) + }) + || classes + .insert( + name.clone(), + DsnClassIdentity { + nets, + via_name, + width, + clearance, + }, + ) + .is_some() + { + bail!("DSN contains an invalid or duplicate class '{name}'"); + } + } + Ok(DsnIdentity { + layers, + components, + nets, + classes, + padstacks, + }) +} + +fn correlate_layers( + baseline: &DsnIdentity, + native: &DsnIdentity, +) -> Result> { + if baseline.layers.len() != native.layers.len() { + bail!("native DSN changed the copper-layer count"); + } + let mappings = baseline + .layers + .iter() + .map(|(index, baseline_name)| { + let native_name = native + .layers + .get(index) + .with_context(|| format!("native DSN omitted layer index {index}"))?; + Ok((baseline_name.clone(), native_name.clone())) + }) + .collect::>>()?; + if mappings.iter().any(|(baseline, native)| baseline != native) { + bail!("native DSN renamed a KiCad copper layer"); + } + Ok(mappings) +} + +fn correlate_components( + baseline: &DsnIdentity, + native: &DsnIdentity, +) -> Result> { + if baseline.components.len() != native.components.len() { + bail!("native DSN changed the component count"); + } + let mut padstacks = BTreeMap::new(); + for (reference, baseline_component) in &baseline.components { + let native_component = native + .components + .get(reference) + .with_context(|| format!("native DSN omitted component '{reference}'"))?; + if baseline_component.x != native_component.x + || baseline_component.y != native_component.y + || baseline_component.side != native_component.side + || baseline_component.rotation != native_component.rotation + || baseline_component.pins.keys().collect::>() + != native_component.pins.keys().collect::>() + { + bail!("native DSN changed placement or pins for component '{reference}'"); + } + for (pin, baseline_pin) in &baseline_component.pins { + let native_pin = native_component.pins.get(pin).unwrap(); + if baseline_pin.x != native_pin.x || baseline_pin.y != native_pin.y { + bail!("native DSN changed pin position for component '{reference}' pin '{pin}'"); + } + let baseline_shape = baseline + .padstacks + .get(&baseline_pin.padstack) + .with_context(|| { + format!("baseline DSN omitted padstack '{}'", baseline_pin.padstack) + })?; + let native_shape = native + .padstacks + .get(&native_pin.padstack) + .with_context(|| { + format!("native DSN omitted padstack '{}'", native_pin.padstack) + })?; + if baseline_shape != native_shape { + bail!("native DSN changed pad geometry for component '{reference}' pin '{pin}'"); + } + if padstacks + .insert(baseline_pin.padstack.clone(), native_pin.padstack.clone()) + .is_some_and(|previous| previous != native_pin.padstack) + { + bail!("native DSN maps one pad geometry to inconsistent padstacks"); + } + } + } + Ok(padstacks) +} + +fn validate_native_nets(baseline: &DsnIdentity, native: &DsnIdentity) -> Result<()> { + if baseline.nets != native.nets { + bail!("native DSN changed net or pin membership"); + } + Ok(()) +} + +fn correlate_classes( + baseline: &DsnIdentity, + native: &DsnIdentity, +) -> Result<(BTreeMap, BTreeMap)> { + if baseline.classes.len() != native.classes.len() { + bail!("native DSN changed the routing-class count"); + } + let mut class_names = BTreeMap::new(); + let mut via_names = BTreeMap::new(); + for (baseline_name, baseline_class) in &baseline.classes { + let matches = native + .classes + .iter() + .filter(|(_, native_class)| native_class.nets == baseline_class.nets) + .collect::>(); + if matches.len() != 1 { + bail!("native DSN has no unique class for '{baseline_name}'"); + } + let (native_name, native_class) = matches[0]; + if baseline_class.width != native_class.width + || baseline_class.clearance != native_class.clearance + { + bail!("native DSN changed width or clearance for class '{baseline_name}'"); + } + let baseline_via = baseline + .padstacks + .get(&baseline_class.via_name) + .with_context(|| format!("baseline DSN omitted via '{}'", baseline_class.via_name))?; + let native_via = native + .padstacks + .get(&native_class.via_name) + .with_context(|| format!("native DSN omitted via '{}'", native_class.via_name))?; + if baseline_via != native_via { + bail!("native DSN changed via geometry for class '{baseline_name}'"); + } + class_names.insert(baseline_name.clone(), native_name.clone()); + if via_names + .insert( + baseline_class.via_name.clone(), + native_class.via_name.clone(), + ) + .is_some_and(|previous| previous != native_class.via_name) + { + bail!("native DSN maps one via rule to inconsistent padstacks"); + } + } + Ok((class_names, via_names)) +} + +fn dsn_atom<'a>(node: &'a SexpNode, index: usize, label: &str) -> Result<&'a str> { + node.get(index) + .and_then(SexpNode::as_str) + .with_context(|| format!("DSN is missing {label}")) +} + +fn dsn_number(node: &SexpNode, index: usize, label: &str) -> Result { + let value = dsn_atom(node, index, label)? + .parse::() + .with_context(|| format!("DSN {label} is not numeric"))?; + if !value.is_finite() { + bail!("DSN {label} is not finite"); + } + Ok(value) +} + +fn dsn_integer(node: &SexpNode, index: usize, label: &str) -> Result { + let value = dsn_number(node, index, label)?; + if value.fract() != 0.0 || value < i64::MIN as f64 || value > i64::MAX as f64 { + bail!("DSN {label} is not a supported integer"); + } + Ok(value as i64) +} + +fn reject_unsupported_board_items(tree: &SexpNode) -> Result<()> { + let unsupported = [ + ("segment", "existing track segment"), + ("arc", "existing routed arc"), + ("via", "existing via"), + ("zone", "copper zone or rule area"), + ]; + for (tag, label) in unsupported { + let count = tree.find_all(tag).len(); + if count > 0 { + bail!("unsupported first routing profile: board contains {count} {label}(s)"); + } + } + Ok(()) +} + +fn copper_layers(tree: &SexpNode) -> Result> { + let layers = tree.find("layers").context("board has no layers table")?; + let mut copper = layers + .children() + .unwrap_or(&[]) + .iter() + .skip(1) + // Layer table rows are `(numeric-id "name" type)`, so the layer name + // is the first data item even though the numeric id is the list head. + .filter_map(|layer| layer.get(1).and_then(SexpNode::as_str)) + .filter(|name| name.ends_with(".Cu")) + .map(str::to_string) + .collect::>(); + copper.dedup(); + if copper != ["F.Cu".to_string(), "B.Cu".to_string()] { + bail!( + "unsupported first routing profile: expected exactly F.Cu and B.Cu, got [{}]", + copper.join(", ") + ); + } + Ok(copper) +} + +fn simple_closed_outline(tree: &SexpNode) -> Result> { + let mut edges = Vec::new(); + for node in tree.children().unwrap_or(&[]) { + if node.find_str("layer") != Some("Edge.Cuts") { + continue; + } + match node.head() { + Some("gr_line") => { + let start = point_um(node, "start").context("Edge.Cuts line has no start")?; + let end = point_um(node, "end").context("Edge.Cuts line has no end")?; + if start == end { + bail!("unsupported outline: zero-length Edge.Cuts line"); + } + edges.push((start, end)); + } + Some(tag) if tag.starts_with("gr_") => { + bail!("unsupported first routing profile: Edge.Cuts '{tag}' is not a straight line") + } + _ => {} + } + } + if edges.len() < 3 { + bail!("unsupported outline: need at least three Edge.Cuts lines"); + } + + let mut adjacency: BTreeMap<(i64, i64), Vec<(i64, i64)>> = BTreeMap::new(); + for (start, end) in &edges { + adjacency.entry(*start).or_default().push(*end); + adjacency.entry(*end).or_default().push(*start); + } + for (point, neighbours) in &mut adjacency { + neighbours.sort(); + neighbours.dedup(); + if neighbours.len() != 2 { + bail!( + "unsupported outline: vertex ({}, {}) has degree {}, expected 2", + point.0, + point.1, + neighbours.len() + ); + } + } + + let start = *adjacency.keys().next().context("outline has no vertices")?; + let mut ordered = vec![start]; + let mut previous = None; + let mut current = start; + loop { + let neighbours = &adjacency[¤t]; + let next = match previous { + None => neighbours[0], + Some(previous) if neighbours[0] == previous => neighbours[1], + Some(_) => neighbours[0], + }; + if next == start { + ordered.push(start); + break; + } + if ordered.contains(&next) { + bail!("unsupported outline: Edge.Cuts contains more than one loop"); + } + ordered.push(next); + previous = Some(current); + current = next; + } + if ordered.len() != edges.len() + 1 { + bail!("unsupported outline: Edge.Cuts is not one closed loop"); + } + Ok(ordered) +} + +fn footprints( + tree: &SexpNode, + net_table: &BTreeMap, +) -> Result> { + let mut output = Vec::new(); + let mut references = BTreeSet::new(); + for footprint in tree.find_all("footprint") { + let layer = footprint + .find_str("layer") + .context("footprint has no layer")?; + if layer != "F.Cu" { + bail!("unsupported first routing profile: footprint on '{layer}'"); + } + if footprint.find("clearance").is_some() { + bail!("unsupported first routing profile: footprint has a local clearance override"); + } + let reference = property_value(footprint, "Reference") + .filter(|value| !value.is_empty()) + .context("footprint has no Reference property")? + .to_string(); + if !references.insert(reference.clone()) { + bail!("duplicate footprint reference '{reference}'"); + } + let kiid = footprint + .find_str("uuid") + .context("footprint has no UUID")? + .to_string(); + let at = footprint.find("at").context("footprint has no position")?; + let x_um = finite_um(at.get_f64(1), "footprint x")?; + let y_um = -finite_um(at.get_f64(2), "footprint y")?; + let rotation_degrees = finite_number(at.get_f64(3).or(Some(0.0)), "footprint rotation")?; + let image_name = format!("konnect_image_{reference}"); + + let mut pads = Vec::new(); + let mut pad_numbers = BTreeSet::new(); + for pad in footprint.find_all("pad") { + let number = pad + .get(1) + .and_then(SexpNode::as_str) + .filter(|number| !number.is_empty()) + .context("footprint contains an unnumbered pad")? + .to_string(); + if !pad_numbers.insert(number.clone()) { + bail!("footprint '{reference}' has duplicate pad number '{number}'"); + } + let pad_type = pad + .get(2) + .and_then(SexpNode::as_str) + .context("pad has no type")?; + if !matches!(pad_type, "smd" | "thru_hole") { + bail!( + "unsupported first routing profile: pad {reference}-{number} has type '{pad_type}'" + ); + } + let shape = match pad.get(3).and_then(SexpNode::as_str) { + Some("circle") => PadShape::Circle, + Some("rect") => PadShape::Rect, + Some(other) => bail!( + "unsupported first routing profile: pad {reference}-{number} has shape '{other}'" + ), + None => bail!("pad {reference}-{number} has no shape"), + }; + let at = pad.find("at").context("pad has no position")?; + let x_um = finite_um(at.get_f64(1), "pad x")?; + let y_um = -finite_um(at.get_f64(2), "pad y")?; + let rotation_degrees = finite_number(at.get_f64(3).or(Some(0.0)), "pad rotation")?; + let size = pad.find("size").context("pad has no size")?; + let size_x_um = positive_um(size.get_f64(1), "pad width")?; + let size_y_um = positive_um(size.get_f64(2), "pad height")?; + if shape == PadShape::Circle && size_x_um != size_y_um { + bail!("circle pad {reference}-{number} does not have equal X/Y size"); + } + if pad.find("clearance").is_some() { + bail!( + "unsupported first routing profile: pad {reference}-{number} has a local clearance override" + ); + } + if pad.find_str("remove_unused_layers") == Some("yes") { + bail!( + "unsupported first routing profile: pad {reference}-{number} removes copper on unused layers" + ); + } + if let Some(offset) = pad.find("offset") { + let offset_x_um = finite_um(offset.get_f64(1), "pad offset x")?; + let offset_y_um = finite_um(offset.get_f64(2), "pad offset y")?; + if offset_x_um != 0 || offset_y_um != 0 { + bail!( + "unsupported first routing profile: pad {reference}-{number} has a non-zero shape offset" + ); + } + } + let layers = pad + .find("layers") + .and_then(SexpNode::children) + .unwrap_or(&[]) + .iter() + .skip(1) + .filter_map(SexpNode::as_str) + .filter(|layer| layer.ends_with(".Cu") || *layer == "*.Cu") + .map(str::to_string) + .collect::>(); + let (layers, drill_um) = if pad_type == "smd" { + if layers != ["F.Cu".to_string()] { + bail!( + "unsupported SMD pad {reference}-{number}: copper layers are [{}]", + layers.join(", ") + ); + } + (layers, None) + } else { + if !(layers == ["*.Cu".to_string()] + || layers == ["F.Cu".to_string(), "B.Cu".to_string()]) + { + bail!( + "unsupported through-hole pad {reference}-{number}: copper layers are [{}]", + layers.join(", ") + ); + } + let drill = pad.find("drill").context("through-hole pad has no drill")?; + if drill.get(1).and_then(SexpNode::as_str) == Some("oval") { + bail!("unsupported through-hole pad {reference}-{number}: oval drill"); + } + let drill_um = positive_um(drill.get_f64(1), "pad drill")?; + if drill_um >= size_x_um.min(size_y_um) { + bail!( + "pad {reference}-{number} drill {drill_um} um is not smaller than its copper size" + ); + } + (vec!["F.Cu".to_string(), "B.Cu".to_string()], Some(drill_um)) + }; + let net = pad + .find("net") + .and_then(|node| resolve_net(node, net_table)); + pads.push(PadModel { + number, + x_um, + y_um, + rotation_degrees, + net, + padstack: PadstackKey { + shape, + layers, + size_x_um, + size_y_um, + drill_um, + }, + }); + } + if pads.is_empty() { + bail!("footprint '{reference}' has no pads"); + } + pads.sort_by(|left, right| left.number.cmp(&right.number)); + output.push(FootprintModel { + reference, + kiid, + image_name, + x_um, + y_um, + rotation_degrees, + pads, + }); + } + output.sort_by(|left, right| left.reference.cmp(&right.reference)); + Ok(output) +} + +fn normalize_rules( + net_pins: &BTreeMap>, + effective_rules: &IpcEffectiveRoutingRules, +) -> Result<(RuleGroups, NetClassNames)> { + let mut class_nets: BTreeMap> = BTreeMap::new(); + for net in net_pins.keys() { + let rules = effective_rules.get(net).with_context(|| { + format!("KiCad returned no effective routing rules for net '{net}'") + })?; + let key = RuleKey { + track_width_um: positive_um(rules.track_width_mm, "track width")?, + clearance_um: non_negative_um(rules.clearance_mm, "clearance")?, + via_diameter_um: positive_um(rules.via_diameter_mm, "via diameter")?, + via_drill_um: positive_um(rules.via_drill_mm, "via drill")?, + }; + if key.via_drill_um >= key.via_diameter_um { + bail!( + "net '{net}' has via drill {} um not smaller than diameter {} um", + key.via_drill_um, + key.via_diameter_um + ); + } + class_nets.entry(key).or_default().push(net.clone()); + } + for nets in class_nets.values_mut() { + nets.sort(); + } + let mut net_classes = BTreeMap::new(); + for (index, nets) in class_nets.values().enumerate() { + let name = format!("konnect_class_{:04}", index + 1); + for net in nets { + net_classes.insert(net.clone(), name.clone()); + } + } + Ok((class_nets, net_classes)) +} + +#[allow(clippy::too_many_arguments)] +fn build_pcb( + board_path: &Path, + copper_layers: &[String], + outline: &[(i64, i64)], + footprints: &[FootprintModel], + net_pins: &BTreeMap>, + class_nets: &BTreeMap>, + net_classes: &BTreeMap, + padstack_names: &BTreeMap, + via_names: &BTreeMap, +) -> Result { + let default_rule = class_nets + .keys() + .next() + .context("routing model has no rule class")?; + let layers = copper_layers + .iter() + .enumerate() + .map(|(index, name)| dsn::Layer { + name: name.clone(), + r#type: "signal".to_string(), + property: Some(dsn::Property { index }), + }) + .collect(); + let boundary = dsn::Boundary::Path(dsn::Path { + layer: "pcb".to_string(), + width: 0.0, + coords: outline + .iter() + .map(|(x, y)| dsn::Point { + x: *x as f64, + y: *y as f64, + }) + .collect(), + }); + + let components = footprints + .iter() + .map(|footprint| dsn::Component { + name: footprint.image_name.clone(), + places: vec![dsn::Place { + name: footprint.reference.clone(), + x: footprint.x_um as f64, + y: footprint.y_um as f64, + side: "front".to_string(), + rotation: footprint.rotation_degrees, + PN: None, + }], + }) + .collect(); + let images = footprints + .iter() + .map(|footprint| dsn::Image { + name: footprint.image_name.clone(), + outlines: Vec::new(), + pins: footprint + .pads + .iter() + .map(|pad| dsn::Pin { + name: padstack_names[&pad.padstack].clone(), + rotate: (pad.rotation_degrees != 0.0).then_some(pad.rotation_degrees), + id: pad.number.clone(), + x: pad.x_um as f64, + y: pad.y_um as f64, + }) + .collect(), + keepouts: dsn::Keepouts(Vec::new()), + }) + .collect(); + + let mut padstacks = padstack_names + .iter() + .map(|(key, name)| padstack(name, key)) + .collect::>(); + padstacks.extend(via_names.iter().map(|(rule, name)| { + dsn::Padstack { + name: name.clone(), + shapes: copper_layers + .iter() + .map(|layer| { + dsn::Shape::Circle(dsn::Circle { + layer: layer.clone(), + diameter: rule.via_diameter_um as f64, + offset: None, + }) + }) + .collect(), + attach: Some(false), + } + })); + padstacks.sort_by(|left, right| left.name.cmp(&right.name)); + + let nets = net_pins + .iter() + .map(|(name, pins)| dsn::NetPinAssignments { + name: name.clone(), + pins: Some(dsn::Pins { + names: pins.clone(), + }), + }) + .collect(); + let classes = class_nets + .iter() + .enumerate() + .map(|(index, (rule, nets))| dsn::Class { + name: format!("konnect_class_{:04}", index + 1), + nets: nets.clone(), + circuit: dsn::Circuit { + use_via: via_names[rule].clone(), + }, + rule: dsn::Rule { + width: rule.track_width_um as f32, + clearances: vec![dsn::Clearance { + value: rule.clearance_um as f32, + r#type: None, + }], + }, + }) + .collect(); + debug_assert_eq!(net_classes.len(), net_pins.len()); + + Ok(dsn::Pcb { + name: board_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("board.kicad_pcb") + .to_string(), + parser: Some(dsn::Parser { + string_quote: Some('"'), + space_in_quoted_tokens: Some(true), + host_cad: Some("Konnect".to_string()), + host_version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + resolution: dsn::Resolution { + unit: "um".to_string(), + value: DSN_RESOLUTION, + }, + unit: Some("um".to_string()), + structure: dsn::Structure { + layers, + boundary, + place_boundary: None, + planes: Vec::new(), + keepouts: dsn::Keepouts(Vec::new()), + via: dsn::ViaNames { + names: via_names.values().cloned().collect(), + }, + grids: Vec::new(), + rules: vec![dsn::StructureRule { + width: Some(default_rule.track_width_um as f32), + clearances: vec![dsn::Clearance { + value: default_rule.clearance_um as f32, + r#type: None, + }], + }], + }, + placement: dsn::Placement { components }, + library: dsn::Library { images, padstacks }, + network: dsn::Network { nets, classes }, + wiring: dsn::Wiring { + wires: Vec::new(), + vias: Vec::new(), + }, + }) +} + +fn padstack(name: &str, key: &PadstackKey) -> dsn::Padstack { + let shapes = key + .layers + .iter() + .map(|layer| match key.shape { + PadShape::Circle => dsn::Shape::Circle(dsn::Circle { + layer: layer.clone(), + diameter: key.size_x_um as f64, + offset: None, + }), + PadShape::Rect => dsn::Shape::Rect(dsn::Rect { + layer: layer.clone(), + x1: -(key.size_x_um as f64) / 2.0, + y1: -(key.size_y_um as f64) / 2.0, + x2: key.size_x_um as f64 / 2.0, + y2: key.size_y_um as f64 / 2.0, + }), + }) + .collect(); + dsn::Padstack { + name: name.to_string(), + shapes, + attach: Some(false), + } +} + +fn serialize_and_validate(pcb: dsn::Pcb) -> Result { + let file = dsn::DsnFile { pcb }; + let mut bytes = Vec::new(); + { + let mut writer = ListWriter::new(&mut bytes); + file.write_dsn(&mut writer) + .context("serialize Specctra DSN")?; + } + let mut text = String::from_utf8(bytes).context("Specctra writer emitted non-UTF-8")?; + if text.starts_with('\n') { + text.remove(0); + } + text.push('\n'); + + let cursor = Cursor::new(text.as_bytes()); + let mut tokenizer = ListTokenizer::new(BufReader::new(cursor)); + dsn::DsnFile::read_dsn(&mut tokenizer) + .map_err(|error| anyhow::anyhow!("generated DSN failed parser round-trip: {error}"))?; + Ok(text) +} + +#[allow(clippy::too_many_arguments)] +fn build_manifest( + board_path: &Path, + source_sha256: &str, + copper_layers: &[String], + footprints: &[FootprintModel], + net_pins: &BTreeMap>, + net_classes: &BTreeMap, + padstack_names: &BTreeMap, + via_names: &BTreeMap, +) -> Result { + let components = footprints + .iter() + .map(|footprint| ManifestComponent { + reference: footprint.reference.clone(), + kiid: footprint.kiid.clone(), + image_name: footprint.image_name.clone(), + x_um: footprint.x_um, + y_um: footprint.y_um, + rotation_degrees: footprint.rotation_degrees, + side: "front".to_string(), + pads: footprint + .pads + .iter() + .map(|pad| ManifestPin { + pad_number: pad.number.clone(), + dsn_pin: format!("{}-{}", footprint.reference, pad.number), + net: pad.net.clone(), + padstack_name: padstack_names[&pad.padstack].clone(), + }) + .collect(), + }) + .collect(); + let nets = net_pins + .iter() + .map(|(name, pins)| ManifestNet { + name: name.clone(), + pins: pins.clone(), + class_name: net_classes[name].clone(), + }) + .collect(); + let mut padstacks = padstack_names + .iter() + .map(|(key, name)| ManifestPadstack { + name: name.clone(), + purpose: "pad".to_string(), + shape: key.shape, + layers: key.layers.clone(), + size_x_um: key.size_x_um, + size_y_um: key.size_y_um, + drill_um: key.drill_um, + }) + .collect::>(); + padstacks.extend(via_names.iter().map(|(rule, name)| ManifestPadstack { + name: name.clone(), + purpose: "via".to_string(), + shape: PadShape::Circle, + layers: copper_layers.to_vec(), + size_x_um: rule.via_diameter_um, + size_y_um: rule.via_diameter_um, + drill_um: Some(rule.via_drill_um), + })); + padstacks.sort_by(|left, right| left.name.cmp(&right.name)); + + serde_json::to_string_pretty(&Manifest { + schema_version: 1, + board_path: board_path.display().to_string(), + source_sha256: source_sha256.to_string(), + coordinate_unit: "um".to_string(), + resolution: DSN_RESOLUTION as u32, + supported_profile: SupportedProfile { + copper_layers: 2, + component_side: "front".to_string(), + pad_shapes: vec!["circle".to_string(), "rect".to_string()], + existing_routing: false, + copper_zones: false, + custom_rules: false, + outline: "one closed loop of straight Edge.Cuts lines".to_string(), + }, + layers: copper_layers + .iter() + .enumerate() + .map(|(index, layer)| ManifestLayer { + kicad_name: layer.clone(), + dsn_name: layer.clone(), + index, + }) + .collect(), + components, + nets, + padstacks, + }) + .context("serialize routing manifest") +} + +fn top_level_net_table(tree: &SexpNode) -> BTreeMap { + tree.find_all("net") + .into_iter() + .filter_map(|net| { + let id = net.get(1)?.as_str()?; + let name = net.get(2)?.as_str()?; + Some((id.to_string(), name.to_string())) + }) + .collect() +} + +fn resolve_net(net: &SexpNode, table: &BTreeMap) -> Option { + if let Some(name) = net.get(2).and_then(SexpNode::as_str) { + return (!name.is_empty()).then(|| name.to_string()); + } + let value = net.get(1)?.as_str()?; + if let Some(name) = table.get(value).filter(|name| !name.is_empty()) { + return Some(name.clone()); + } + // KiCad 10 stores the net name directly as `(net "NAME")`. Older board + // files used `(net id "NAME")`, handled by the branch above. + (!value.is_empty()).then(|| value.to_string()) +} + +fn property_value<'a>(node: &'a SexpNode, property_name: &str) -> Option<&'a str> { + node.find_all("property") + .into_iter() + .find(|property| property.get(1).and_then(SexpNode::as_str) == Some(property_name))? + .get(2)? + .as_str() +} + +fn point_um(node: &SexpNode, tag: &str) -> Result<(i64, i64)> { + let point = node.find(tag).with_context(|| format!("missing '{tag}'"))?; + Ok(( + finite_um(point.get_f64(1), tag)?, + -finite_um(point.get_f64(2), tag)?, + )) +} + +fn finite_number(value: Option, label: &str) -> Result { + let value = value.with_context(|| format!("missing {label}"))?; + if !value.is_finite() { + bail!("{label} is not finite"); + } + Ok(value) +} + +fn finite_um(value: Option, label: &str) -> Result { + let value = finite_number(value, label)? * UM_PER_MM; + if value < i64::MIN as f64 || value > i64::MAX as f64 { + bail!("{label} is outside the supported coordinate range"); + } + Ok(value.round() as i64) +} + +fn positive_um(value: Option, label: &str) -> Result { + let value = finite_um(value, label)?; + if value <= 0 { + bail!("{label} must be greater than zero"); + } + Ok(value) +} + +fn non_negative_um(value: Option, label: &str) -> Result { + let value = finite_um(value, label)?; + if value < 0 { + bail!("{label} must not be negative"); + } + Ok(value) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use konnect_ipc::IpcRoutingRules; + + fn rules() -> IpcEffectiveRoutingRules { + ["GND", "VCC"] + .into_iter() + .map(|net| { + ( + net.to_string(), + IpcRoutingRules { + class_name: "Default".to_string(), + constituents: vec!["Default".to_string()], + track_width_mm: Some(0.25), + clearance_mm: Some(0.2), + via_diameter_mm: Some(0.6), + via_drill_mm: Some(0.3), + }, + ) + }) + .collect() + } + + fn native_fixture_rules() -> IpcEffectiveRoutingRules { + let mut rules = rules(); + for rule in rules.values_mut() { + rule.track_width_mm = Some(0.2); + } + rules + } + + #[test] + fn deterministic_export_round_trips_through_specctra_parser() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let first = export_dsn(Path::new("board.kicad_pcb"), source, &rules()).unwrap(); + let second = export_dsn(Path::new("board.kicad_pcb"), source, &rules()).unwrap(); + + assert_eq!(first.dsn, second.dsn); + assert_eq!(first.manifest, second.manifest); + assert_eq!(first.component_count, 2); + assert_eq!(first.pad_count, 4); + assert_eq!(first.net_count, 2); + assert!(first.dsn.contains("(pcb board.kicad_pcb")); + assert!(first.dsn.contains("(boundary")); + assert!(first.dsn.contains("(net GND")); + assert!(first.dsn.contains("R1-1")); + let manifest: serde_json::Value = serde_json::from_str(&first.manifest).unwrap(); + assert_eq!(manifest["components"][0]["reference"], "R1"); + assert_eq!(manifest["components"][0]["x_um"], 100_000); + assert_eq!(manifest["components"][0]["y_um"], -50_000); + assert_eq!(manifest["components"][0]["rotation_degrees"], 0.0); + assert_eq!(manifest["components"][0]["side"], "front"); + } + + #[test] + fn freerouting_owned_dsn_corpus_parses() { + let source = include_str!("../tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn"); + let mut tokenizer = ListTokenizer::new(BufReader::new(Cursor::new(source.as_bytes()))); + dsn::DsnFile::read_dsn(&mut tokenizer) + .expect("Freerouting v2.3.0 corpus fixture must remain parseable"); + } + + #[test] + fn native_kicad_dsn_rewrites_manifest_identifiers_without_changing_semantics() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let native = include_str!("../tests/fixtures/specctra_two_resistors.native-kicad-10.dsn"); + let baseline = export_dsn( + Path::new("board.kicad_pcb"), + source, + &native_fixture_rules(), + ) + .unwrap(); + let adopted = adopt_native_dsn(baseline, native.to_string()).unwrap(); + let manifest: serde_json::Value = serde_json::from_str(&adopted.manifest).unwrap(); + + assert_eq!(adopted.dsn, native); + assert_eq!( + manifest["components"][0]["image_name"], + "Resistor_SMD:R_0402" + ); + assert_eq!( + manifest["components"][0]["pads"][0]["padstack_name"], + "Rect[T]Pad_600.000000x500.000000_um" + ); + assert_eq!(manifest["nets"][0]["class_name"], "kicad_default"); + assert!(manifest["padstacks"] + .as_array() + .unwrap() + .iter() + .any(|padstack| padstack["name"] == "Via[0-1]_600:300_um")); + } + + /// Optional local parity check against the Freerouting engine. CI does not + /// install Java or Freerouting; maintainers can opt in with + /// `FREEROUTING_JAR=/path/to/freerouting.jar cargo test -p konnect-core + /// freerouting_accepts_exported_fixture -- --ignored`. + #[test] + #[ignore = "requires Java and FREEROUTING_JAR"] + fn freerouting_accepts_exported_fixture() { + let jar = std::env::var_os("FREEROUTING_JAR").expect("set FREEROUTING_JAR"); + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let temp = tempfile::tempdir().expect("tempdir"); + let board_path = temp.path().join("board.kicad_pcb"); + let dsn_path = temp.path().join("board.dsn"); + let ses_path = temp.path().join("board.ses"); + std::fs::write(&board_path, source).expect("write board fixture"); + let export = export_dsn(&board_path, source, &rules()).unwrap(); + std::fs::write(&dsn_path, export.dsn).expect("write DSN"); + + let output = std::process::Command::new("java") + .arg("-jar") + .arg(jar) + .arg("-de") + .arg(&dsn_path) + .arg("-do") + .arg(&ses_path) + .arg("-mp") + .arg("2") + .output() + .expect("launch Freerouting"); + + assert!( + output.status.success(), + "Freerouting refused generated DSN:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + std::fs::metadata(&ses_path) + .map(|metadata| metadata.len() > 0) + .unwrap_or(false), + "Freerouting did not produce a non-empty SES" + ); + let ses = std::fs::read_to_string(&ses_path).expect("read Freerouting SES"); + crate::specctra_ses::parse_import_plan(&board_path, source, &export.manifest, &ses) + .expect("Freerouting SES must pass Konnect's strict import planner"); + } + + /// Real-engine parity for KiCad 10's native exporter identifiers plus the + /// rewritten revision manifest consumed by the strict SES planner. + #[test] + #[ignore = "requires Java and FREEROUTING_JAR"] + fn freerouting_round_trips_native_kicad_export() { + let jar = std::env::var_os("FREEROUTING_JAR").expect("set FREEROUTING_JAR"); + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let native = include_str!("../tests/fixtures/specctra_two_resistors.native-kicad-10.dsn"); + let temp = tempfile::tempdir().expect("tempdir"); + let board_path = temp.path().join("board.kicad_pcb"); + let dsn_path = temp.path().join("board.dsn"); + let ses_path = temp.path().join("board.ses"); + std::fs::write(&board_path, source).expect("write board fixture"); + let baseline = export_dsn(&board_path, source, &native_fixture_rules()).unwrap(); + let export = adopt_native_dsn(baseline, native.to_string()).unwrap(); + std::fs::write(&dsn_path, &export.dsn).expect("write native DSN"); + + let output = std::process::Command::new("java") + .arg("-jar") + .arg(jar) + .arg("-de") + .arg(&dsn_path) + .arg("-do") + .arg(&ses_path) + .arg("-mp") + .arg("2") + .output() + .expect("run Freerouting"); + assert!( + output.status.success(), + "Freerouting failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let ses = std::fs::read_to_string(&ses_path).expect("read SES"); + let plan = + crate::specctra_ses::parse_import_plan(&board_path, source, &export.manifest, &ses) + .expect("strict SES planner accepts native KiCad identifiers"); + assert!(!plan.tracks.is_empty() || !plan.vias.is_empty()); + } + + #[test] + fn incomplete_effective_rules_are_refused() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb"); + let mut rules = rules(); + rules.get_mut("GND").unwrap().via_drill_mm = None; + let error = export_dsn(Path::new("board.kicad_pcb"), source, &rules) + .unwrap_err() + .to_string(); + assert!(error.contains("via drill"), "{error}"); + } + + #[test] + fn existing_routing_is_refused_before_export() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb").replace( + "\n)", + "\n (segment (start 1 1) (end 2 2) (width 0.2) (layer \"F.Cu\") (net 1))\n)", + ); + let error = export_dsn(Path::new("board.kicad_pcb"), &source, &rules()) + .unwrap_err() + .to_string(); + assert!(error.contains("existing track segment"), "{error}"); + } + + #[test] + fn branched_outline_is_refused() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb") + .replace( + "\n)", + "\n (gr_line (start 80 30) (end 90 40) (stroke (width 0.05) (type default)) (layer \"Edge.Cuts\") (uuid \"branch\"))\n)", + ); + let error = export_dsn(Path::new("board.kicad_pcb"), &source, &rules()) + .unwrap_err() + .to_string(); + assert!(error.contains("degree"), "{error}"); + } + + #[test] + fn nonzero_pad_shape_offset_is_refused() { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb").replacen( + "(size 0.6 0.5)", + "(size 0.6 0.5)\n\t\t\t(offset 0.1 0)", + 1, + ); + let error = export_dsn(Path::new("board.kicad_pcb"), &source, &rules()) + .unwrap_err() + .to_string(); + assert!(error.contains("shape offset"), "{error}"); + } +} diff --git a/crates/konnect-core/src/specctra_ses.rs b/crates/konnect-core/src/specctra_ses.rs new file mode 100644 index 00000000..00b99e4f --- /dev/null +++ b/crates/konnect-core/src/specctra_ses.rs @@ -0,0 +1,935 @@ +//! Strict, provenance-bound Specctra SES import planning. +//! +//! This module performs no KiCad mutation. It fully validates the reverse +//! manifest, the Freerouting session, placement invariance, net/layer names, +//! via geometry, and every route primitive before returning an apply plan. + +use anyhow::{bail, Context, Result}; +use konnect_sexp::{parse_sexp, SexpNode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +const UM_PER_MM: f64 = 1_000.0; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SesImportPlan { + pub board_path: String, + pub source_sha256: String, + pub session_id: String, + pub tracks: Vec, + pub arcs: Vec, + pub vias: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SesTrack { + pub net_name: String, + pub layer: String, + pub width_mm: f64, + pub x1_mm: f64, + pub y1_mm: f64, + pub x2_mm: f64, + pub y2_mm: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SesArc { + pub net_name: String, + pub layer: String, + pub width_mm: f64, + pub start_x_mm: f64, + pub start_y_mm: f64, + pub mid_x_mm: f64, + pub mid_y_mm: f64, + pub end_x_mm: f64, + pub end_y_mm: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SesVia { + pub net_name: String, + pub padstack_name: String, + pub x_mm: f64, + pub y_mm: f64, + pub drill_mm: f64, + pub size_mm: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + schema_version: u32, + board_path: String, + source_sha256: String, + coordinate_unit: String, + resolution: u32, + supported_profile: SupportedProfile, + layers: Vec, + components: Vec, + nets: Vec, + padstacks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SupportedProfile { + copper_layers: u32, + component_side: String, + pad_shapes: Vec, + existing_routing: bool, + copper_zones: bool, + custom_rules: bool, + outline: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestLayer { + kicad_name: String, + dsn_name: String, + index: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestComponent { + reference: String, + kiid: String, + image_name: String, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + side: String, + pads: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestPin { + pad_number: String, + dsn_pin: String, + net: Option, + padstack_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestNet { + name: String, + pins: Vec, + class_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestPadstack { + name: String, + purpose: String, + shape: String, + layers: Vec, + size_x_um: i64, + size_y_um: i64, + drill_um: Option, +} + +pub(crate) fn parse_import_plan( + board_path: &Path, + board_source: &str, + manifest_source: &str, + ses_source: &str, +) -> Result { + let manifest: Manifest = + serde_json::from_str(manifest_source).context("parse Specctra reverse manifest")?; + validate_manifest(board_path, board_source, &manifest)?; + + let root = parse_sexp(ses_source).context("parse Specctra SES")?; + require_head(&root, "session")?; + require_direct_shape(&root, 1, &["base_design", "placement", "was_is", "routes"])?; + let session_id = atom(&root, 1, "session id")?.to_string(); + + let base_design = one_child(&root, "base_design")?; + require_direct_shape(base_design, 1, &[])?; + let expected_base = board_path + .file_stem() + .and_then(|value| value.to_str()) + .context("board path has no UTF-8 file stem")?; + let actual_base = atom(base_design, 1, "base_design name")?; + if actual_base != expected_base { + bail!("SES base_design '{actual_base}' does not match board '{expected_base}'"); + } + + let was_is = one_child(&root, "was_is")?; + require_direct_shape(was_is, 0, &[])?; + validate_placement(one_child(&root, "placement")?, &manifest)?; + + let routes = one_child(&root, "routes")?; + require_direct_shape( + routes, + 0, + &["resolution", "parser", "library_out", "network_out"], + )?; + let route_resolution = parse_resolution(one_child(routes, "resolution")?)?; + if let Some(parser) = optional_child(routes, "parser")? { + validate_parser(parser)?; + } + validate_library( + one_child(routes, "library_out")?, + route_resolution, + &manifest, + )?; + let (tracks, arcs, vias) = parse_network( + one_child(routes, "network_out")?, + route_resolution, + &manifest, + )?; + if tracks.is_empty() && arcs.is_empty() && vias.is_empty() { + bail!("SES contains no route primitives"); + } + + Ok(SesImportPlan { + board_path: manifest.board_path, + source_sha256: manifest.source_sha256, + session_id, + tracks, + arcs, + vias, + }) +} + +fn validate_manifest(board_path: &Path, board_source: &str, manifest: &Manifest) -> Result<()> { + if manifest.schema_version != 1 { + bail!( + "unsupported Specctra manifest schema {}", + manifest.schema_version + ); + } + if manifest.coordinate_unit != "um" || manifest.resolution != 10 { + bail!( + "unsupported manifest coordinate system: {} resolution {}", + manifest.coordinate_unit, + manifest.resolution + ); + } + if manifest.supported_profile.copper_layers != 2 + || manifest.supported_profile.component_side != "front" + || manifest.supported_profile.existing_routing + || manifest.supported_profile.copper_zones + || manifest.supported_profile.custom_rules + || manifest.supported_profile.outline.is_empty() + || manifest.supported_profile.pad_shapes.is_empty() + { + bail!("manifest does not describe the supported first routing profile"); + } + let requested = canonical_existing(board_path)?; + let recorded = canonical_existing(Path::new(&manifest.board_path))?; + if requested != recorded { + bail!( + "manifest board '{}' does not match requested board '{}'", + recorded.display(), + requested.display() + ); + } + let actual_hash = sha256_hex(board_source.as_bytes()); + if actual_hash != manifest.source_sha256 { + bail!( + "live board revision does not match routing manifest (expected {}, got {})", + manifest.source_sha256, + actual_hash + ); + } + validate_manifest_relations(manifest) +} + +fn validate_manifest_relations(manifest: &Manifest) -> Result<()> { + let mut layer_names = BTreeSet::new(); + let mut layer_indices = BTreeSet::new(); + for layer in &manifest.layers { + if layer.kicad_name.is_empty() + || layer.dsn_name.is_empty() + || !layer_names.insert(layer.dsn_name.as_str()) + || !layer_indices.insert(layer.index) + { + bail!("manifest contains an invalid or duplicate layer mapping"); + } + } + if layer_names.len() != 2 { + bail!("manifest must contain exactly two copper layer mappings"); + } + + let net_names = manifest + .nets + .iter() + .map(|net| net.name.as_str()) + .collect::>(); + if net_names.len() != manifest.nets.len() || net_names.contains("") { + bail!("manifest contains an empty or duplicate net name"); + } + let padstack_names = manifest + .padstacks + .iter() + .map(|padstack| padstack.name.as_str()) + .collect::>(); + if padstack_names.len() != manifest.padstacks.len() || padstack_names.contains("") { + bail!("manifest contains an empty or duplicate padstack name"); + } + let mut references = BTreeSet::new(); + for component in &manifest.components { + if component.reference.is_empty() + || component.kiid.is_empty() + || component.image_name.is_empty() + || !component.rotation_degrees.is_finite() + || component.side != "front" + || !references.insert(component.reference.as_str()) + { + bail!("manifest contains invalid component placement metadata"); + } + for pin in &component.pads { + if pin.pad_number.is_empty() + || pin.dsn_pin != format!("{}-{}", component.reference, pin.pad_number) + || !padstack_names.contains(pin.padstack_name.as_str()) + || pin + .net + .as_deref() + .is_some_and(|net| !net_names.contains(net)) + { + bail!( + "manifest contains invalid pin mapping for {}", + component.reference + ); + } + } + } + for net in &manifest.nets { + if net.class_name.is_empty() || net.pins.is_empty() { + bail!("manifest net '{}' is incomplete", net.name); + } + } + Ok(()) +} + +fn validate_placement(node: &SexpNode, manifest: &Manifest) -> Result<()> { + require_direct_shape(node, 0, &["resolution", "component"])?; + let resolution = parse_resolution(one_child(node, "resolution")?)?; + let expected = manifest + .components + .iter() + .map(|component| (component.reference.as_str(), component)) + .collect::>(); + let mut observed = BTreeSet::new(); + for component_node in node.find_all("component") { + require_direct_shape(component_node, 1, &["place"])?; + let image_name = atom(component_node, 1, "placement component image")?; + for place in component_node.find_all("place") { + require_direct_shape(place, 5, &[])?; + let reference = atom(place, 1, "place reference")?; + let component = expected.get(reference).with_context(|| { + format!("SES placement contains unknown component '{reference}'") + })?; + if image_name != component.image_name { + bail!( + "SES image for '{reference}' changed from '{}' to '{image_name}'", + component.image_name + ); + } + if !observed.insert(reference.to_string()) { + bail!("SES placement repeats component '{reference}'"); + } + let x_um = scaled_i64(number(place, 2, "place x")?, resolution, "place x")?; + let y_um = scaled_i64(number(place, 3, "place y")?, resolution, "place y")?; + let side = atom(place, 4, "place side")?; + let rotation = number(place, 5, "place rotation")?; + if x_um != component.x_um + || y_um != component.y_um + || side != component.side + || (rotation - component.rotation_degrees).abs() > 1e-9 + { + bail!("SES changes placement of component '{reference}'"); + } + } + } + if observed.len() != expected.len() { + let missing = expected + .keys() + .filter(|reference| !observed.contains(**reference)) + .copied() + .collect::>(); + bail!("SES placement omits component(s): {}", missing.join(", ")); + } + Ok(()) +} + +fn validate_parser(node: &SexpNode) -> Result<()> { + require_direct_shape( + node, + 0, + &[ + "string_quote", + "space_in_quoted_tokens", + "host_cad", + "host_version", + ], + )?; + for child in node.children().unwrap_or(&[]).iter().skip(1) { + require_direct_shape(child, 1, &[])?; + } + Ok(()) +} + +fn validate_library(node: &SexpNode, resolution: f64, manifest: &Manifest) -> Result<()> { + require_direct_shape(node, 0, &["padstack"])?; + let vias = manifest + .padstacks + .iter() + .filter(|padstack| padstack.purpose == "via") + .map(|padstack| (padstack.name.as_str(), padstack)) + .collect::>(); + let mut observed = BTreeSet::new(); + for padstack in node.find_all("padstack") { + require_direct_shape(padstack, 1, &["shape", "attach"])?; + let name = atom(padstack, 1, "library_out padstack name")?; + let expected = vias + .get(name) + .with_context(|| format!("SES library contains unknown via padstack '{name}'"))?; + if expected.shape != "circle" + || expected.size_x_um != expected.size_y_um + || expected.drill_um.is_none() + { + bail!("manifest via padstack '{name}' is not a supported round through via"); + } + if !observed.insert(name.to_string()) { + bail!("SES library repeats padstack '{name}'"); + } + let mut shape_layers = BTreeSet::new(); + for shape in padstack.find_all("shape") { + require_direct_shape(shape, 0, &["circle"])?; + let circle = one_child(shape, "circle")?; + require_direct_shape(circle, 4, &[])?; + let layer = atom(circle, 1, "via circle layer")?; + if !expected.layers.iter().any(|candidate| candidate == layer) { + bail!("SES via padstack '{name}' has unexpected layer '{layer}'"); + } + if !shape_layers.insert(layer) { + bail!("SES via padstack '{name}' repeats layer '{layer}'"); + } + let diameter_um = scaled_i64( + number(circle, 2, "via diameter")?, + resolution, + "via diameter", + )?; + let x_offset = scaled_i64( + number(circle, 3, "via x offset")?, + resolution, + "via x offset", + )?; + let y_offset = scaled_i64( + number(circle, 4, "via y offset")?, + resolution, + "via y offset", + )?; + if diameter_um != expected.size_x_um || x_offset != 0 || y_offset != 0 { + bail!("SES via padstack '{name}' geometry differs from the manifest"); + } + } + if shape_layers.len() != expected.layers.len() { + bail!("SES via padstack '{name}' does not cover every expected copper layer"); + } + if let Some(attach) = optional_child(padstack, "attach")? { + require_direct_shape(attach, 1, &[])?; + if atom(attach, 1, "attach value")? != "off" { + bail!("SES via padstack '{name}' has unsupported attach mode"); + } + } + } + Ok(()) +} + +fn parse_network( + node: &SexpNode, + resolution: f64, + manifest: &Manifest, +) -> Result<(Vec, Vec, Vec)> { + require_direct_shape(node, 0, &["net"])?; + let nets = manifest + .nets + .iter() + .map(|net| net.name.as_str()) + .collect::>(); + let layers = manifest + .layers + .iter() + .map(|layer| (layer.dsn_name.as_str(), layer.kicad_name.as_str())) + .collect::>(); + let vias_by_name = manifest + .padstacks + .iter() + .filter(|padstack| padstack.purpose == "via") + .map(|padstack| (padstack.name.as_str(), padstack)) + .collect::>(); + let mut tracks = Vec::new(); + let mut arcs = Vec::new(); + let mut vias = Vec::new(); + let mut seen_tracks = BTreeSet::new(); + let mut seen_arcs = BTreeSet::new(); + let mut seen_vias = BTreeSet::new(); + + for net_node in node.find_all("net") { + require_direct_shape(net_node, 1, &["wire", "via"])?; + let net_name = atom(net_node, 1, "network_out net name")?; + if !nets.contains(net_name) { + bail!("SES route refers to unknown net '{net_name}'"); + } + for wire in net_node.find_all("wire") { + require_direct_shape(wire, 0, &["path", "qarc"])?; + let path = optional_child(wire, "path")?; + let qarc = optional_child(wire, "qarc")?; + match (path, qarc) { + (Some(path), None) => { + let data = atom_values(path)?; + if data.len() < 6 || (data.len() - 2) % 2 != 0 { + bail!( + "SES path for net '{net_name}' does not contain complete point pairs" + ); + } + let layer = layers + .get(data[0]) + .with_context(|| format!("SES path uses unknown layer '{}'", data[0]))?; + let width_um = scaled_positive( + number_text(data[1], "path width")?, + resolution, + "path width", + )?; + let width_mm = width_um / UM_PER_MM; + let points = data[2..] + .chunks_exact(2) + .map(|pair| { + let x_um = + scaled(number_text(pair[0], "path x")?, resolution, "path x")?; + let y_um = + scaled(number_text(pair[1], "path y")?, resolution, "path y")?; + Ok((x_um / UM_PER_MM, -y_um / UM_PER_MM)) + }) + .collect::>>()?; + for pair in points.windows(2) { + let (x1_mm, y1_mm) = pair[0]; + let (x2_mm, y2_mm) = pair[1]; + if x1_mm == x2_mm && y1_mm == y2_mm { + bail!("SES path for net '{net_name}' contains a zero-length segment"); + } + let mut endpoints = [ + (ordered_f64(x1_mm), ordered_f64(y1_mm)), + (ordered_f64(x2_mm), ordered_f64(y2_mm)), + ]; + endpoints.sort(); + let key = ( + net_name.to_string(), + (*layer).to_string(), + ordered_f64(width_mm), + endpoints, + ); + if !seen_tracks.insert(key) { + bail!("SES repeats a route segment on net '{net_name}'"); + } + tracks.push(SesTrack { + net_name: net_name.to_string(), + layer: (*layer).to_string(), + width_mm, + x1_mm, + y1_mm, + x2_mm, + y2_mm, + }); + } + } + (None, Some(qarc)) => { + let data = atom_values(qarc)?; + if data.len() != 8 { + bail!("SES qarc for net '{net_name}' must contain layer, width, start, end, and center"); + } + let layer = layers + .get(data[0]) + .with_context(|| format!("SES qarc uses unknown layer '{}'", data[0]))?; + let width_mm = scaled_positive( + number_text(data[1], "qarc width")?, + resolution, + "qarc width", + )? / UM_PER_MM; + let point = |x: &str, y: &str, label: &str| -> Result<(f64, f64)> { + Ok(( + scaled(number_text(x, &format!("{label} x"))?, resolution, label)? + / UM_PER_MM, + -scaled(number_text(y, &format!("{label} y"))?, resolution, label)? + / UM_PER_MM, + )) + }; + let start = point(data[2], data[3], "qarc start")?; + let end = point(data[4], data[5], "qarc end")?; + let center = point(data[6], data[7], "qarc center")?; + let start_vector = (start.0 - center.0, start.1 - center.1); + let end_vector = (end.0 - center.0, end.1 - center.1); + let start_radius = start_vector.0.hypot(start_vector.1); + let end_radius = end_vector.0.hypot(end_vector.1); + let scale = start_radius.max(end_radius).max(1.0); + if start_radius <= f64::EPSILON + || (start_radius - end_radius).abs() > 1e-6 * scale + || (start_vector.0 * end_vector.0 + start_vector.1 * end_vector.1).abs() + > 1e-6 * start_radius * end_radius + { + bail!("SES qarc for net '{net_name}' is not a finite quarter-circle"); + } + let bisector = (start_vector.0 + end_vector.0, start_vector.1 + end_vector.1); + let bisector_length = bisector.0.hypot(bisector.1); + if bisector_length <= f64::EPSILON { + bail!("SES qarc for net '{net_name}' has no unique midpoint"); + } + let mid = ( + center.0 + start_radius * bisector.0 / bisector_length, + center.1 + start_radius * bisector.1 / bisector_length, + ); + let key = ( + net_name.to_string(), + (*layer).to_string(), + ordered_f64(width_mm), + (ordered_f64(start.0), ordered_f64(start.1)), + (ordered_f64(end.0), ordered_f64(end.1)), + (ordered_f64(center.0), ordered_f64(center.1)), + ); + if !seen_arcs.insert(key) { + bail!("SES repeats an arc on net '{net_name}'"); + } + arcs.push(SesArc { + net_name: net_name.to_string(), + layer: (*layer).to_string(), + width_mm, + start_x_mm: start.0, + start_y_mm: start.1, + mid_x_mm: mid.0, + mid_y_mm: mid.1, + end_x_mm: end.0, + end_y_mm: end.1, + }); + } + _ => bail!("SES wire for net '{net_name}' must contain exactly one path or qarc"), + } + } + for via_node in net_node.find_all("via") { + require_direct_shape(via_node, 3, &["net", "type"])?; + let padstack_name = atom(via_node, 1, "via padstack")?; + let padstack = vias_by_name + .get(padstack_name) + .with_context(|| format!("SES via uses unknown padstack '{padstack_name}'"))?; + let nested_net = one_child(via_node, "net")?; + require_direct_shape(nested_net, 1, &[])?; + if atom(nested_net, 1, "via net")? != net_name { + bail!("SES via net does not match enclosing net '{net_name}'"); + } + if let Some(kind) = optional_child(via_node, "type")? { + require_direct_shape(kind, 1, &[])?; + if atom(kind, 1, "via type")? != "protect" { + bail!("SES via has unsupported type"); + } + } + let x_um = scaled(number(via_node, 2, "via x")?, resolution, "via x")?; + let y_um = scaled(number(via_node, 3, "via y")?, resolution, "via y")?; + let x_mm = x_um / UM_PER_MM; + let y_mm = -y_um / UM_PER_MM; + let key = (net_name.to_string(), ordered_f64(x_mm), ordered_f64(y_mm)); + if !seen_vias.insert(key) { + bail!("SES repeats a via on net '{net_name}'"); + } + vias.push(SesVia { + net_name: net_name.to_string(), + padstack_name: padstack_name.to_string(), + x_mm, + y_mm, + drill_mm: padstack.drill_um.context("manifest via has no drill")? as f64 + / UM_PER_MM, + size_mm: padstack.size_x_um as f64 / UM_PER_MM, + }); + } + } + Ok((tracks, arcs, vias)) +} + +fn parse_resolution(node: &SexpNode) -> Result { + require_direct_shape(node, 2, &[])?; + if atom(node, 1, "resolution unit")? != "um" { + bail!("only SES resolution unit 'um' is supported"); + } + let resolution = number(node, 2, "resolution")?; + if !resolution.is_finite() || resolution <= 0.0 || resolution.fract() != 0.0 { + bail!("SES resolution must be a positive integer"); + } + Ok(resolution) +} + +fn require_head(node: &SexpNode, expected: &str) -> Result<()> { + if node.head() != Some(expected) { + bail!("expected ({expected} ...), got {:?}", node.head()); + } + Ok(()) +} + +fn require_direct_shape(node: &SexpNode, atom_count: usize, allowed_lists: &[&str]) -> Result<()> { + let children = node.children().context("expected SES list")?; + let mut seen_list = false; + for (index, child) in children.iter().skip(1).enumerate() { + match child { + SexpNode::Atom(_) | SexpNode::Str(_) if !seen_list && index < atom_count => {} + SexpNode::List(_) => { + seen_list = true; + let tag = child.head().context("SES contains an empty list")?; + if !allowed_lists.contains(&tag) { + bail!( + "unsupported SES field '{tag}' inside '{}'", + node.head().unwrap_or("") + ); + } + } + _ => bail!( + "unexpected SES value inside '{}'", + node.head().unwrap_or("") + ), + } + } + let actual_atoms = children + .iter() + .skip(1) + .take_while(|child| child.as_str().is_some()) + .count(); + if actual_atoms != atom_count { + bail!( + "SES '{}' expects {atom_count} direct value(s), got {actual_atoms}", + node.head().unwrap_or("") + ); + } + Ok(()) +} + +fn one_child<'a>(node: &'a SexpNode, tag: &str) -> Result<&'a SexpNode> { + let children = node.find_all(tag); + if children.len() != 1 { + bail!( + "SES '{}' must contain exactly one '{tag}', got {}", + node.head().unwrap_or(""), + children.len() + ); + } + Ok(children[0]) +} + +fn optional_child<'a>(node: &'a SexpNode, tag: &str) -> Result> { + let children = node.find_all(tag); + if children.len() > 1 { + bail!("SES '{}' repeats '{tag}'", node.head().unwrap_or("")); + } + Ok(children.first().copied()) +} + +fn atom<'a>(node: &'a SexpNode, index: usize, label: &str) -> Result<&'a str> { + node.get(index) + .and_then(SexpNode::as_str) + .with_context(|| format!("SES {label} is missing")) +} + +fn number(node: &SexpNode, index: usize, label: &str) -> Result { + number_text(atom(node, index, label)?, label) +} + +fn number_text(value: &str, label: &str) -> Result { + let number = value + .parse::() + .with_context(|| format!("SES {label} is not a number"))?; + if !number.is_finite() { + bail!("SES {label} is not finite"); + } + Ok(number) +} + +fn atom_values(node: &SexpNode) -> Result> { + let children = node.children().context("expected SES list")?; + children + .iter() + .skip(1) + .map(|child| child.as_str().context("unexpected nested SES route field")) + .collect() +} + +fn scaled(value: f64, resolution: f64, label: &str) -> Result { + let scaled = value / resolution; + if !scaled.is_finite() || scaled.abs() > 1_000_000_000.0 { + bail!("SES {label} is outside the supported coordinate range"); + } + Ok(scaled) +} + +fn scaled_positive(value: f64, resolution: f64, label: &str) -> Result { + let value = scaled(value, resolution, label)?; + if value <= 0.0 { + bail!("SES {label} must be positive"); + } + Ok(value) +} + +fn scaled_i64(value: f64, resolution: f64, label: &str) -> Result { + let value = scaled(value, resolution, label)?; + if value.fract().abs() > 1e-9 { + bail!("SES {label} does not resolve to a whole micrometre"); + } + Ok(value as i64) +} + +fn ordered_f64(value: f64) -> u64 { + let bits = value.to_bits(); + if bits >> 63 == 0 { + bits | (1 << 63) + } else { + !bits + } +} + +fn canonical_existing(path: &Path) -> Result { + std::fs::canonicalize(path).with_context(|| format!("canonicalize {}", path.display())) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::specctra::export_dsn; + use konnect_ipc::{IpcEffectiveRoutingRules, IpcRoutingRules}; + + fn rules() -> IpcEffectiveRoutingRules { + ["GND", "VCC"] + .into_iter() + .map(|net| { + ( + net.to_string(), + IpcRoutingRules { + class_name: "Default".to_string(), + constituents: vec!["Default".to_string()], + track_width_mm: Some(0.25), + clearance_mm: Some(0.2), + via_diameter_mm: Some(0.6), + via_drill_mm: Some(0.3), + }, + ) + }) + .collect() + } + + fn sample_ses() -> &'static str { + r#"(session board + (base_design board) + (placement + (resolution um 10) + (component konnect_image_R1 (place R1 1000000 -500000 front 0)) + (component konnect_image_R2 (place R2 1100000 -500000 front 0)) + ) + (was_is) + (routes + (resolution um 10) + (parser (host_cad KiCad) (host_version 10.0.5)) + (library_out + (padstack konnect_via_0001 + (shape (circle F.Cu 6000 0 0)) + (shape (circle B.Cu 6000 0 0)) + (attach off) + ) + ) + (network_out + (net GND + (wire (path F.Cu 2500 995000 -500000 1050000 -500000 1050000 -510000)) + (via konnect_via_0001 1050000 -510000 (net GND) (type protect)) + ) + ) + ) +)"# + } + + fn fixture() -> (tempfile::TempDir, std::path::PathBuf, String, String) { + let source = include_str!("../tests/fixtures/specctra_two_resistors.kicad_pcb").to_string(); + let dir = tempfile::tempdir().unwrap(); + let board = dir.path().join("board.kicad_pcb"); + std::fs::write(&board, &source).unwrap(); + let export = export_dsn(&board, &source, &rules()).unwrap(); + (dir, board, source, export.manifest) + } + + #[test] + fn valid_session_lowers_to_kicad_coordinates() { + let (_dir, board, source, manifest) = fixture(); + let plan = parse_import_plan(&board, &source, &manifest, sample_ses()).unwrap(); + assert_eq!(plan.tracks.len(), 2); + assert_eq!(plan.vias.len(), 1); + assert!(plan.arcs.is_empty()); + assert_eq!(plan.tracks[0].width_mm, 0.25); + assert_eq!(plan.tracks[0].x1_mm, 99.5); + assert_eq!(plan.tracks[0].y1_mm, 50.0); + assert_eq!(plan.vias[0].x_mm, 105.0); + assert_eq!(plan.vias[0].y_mm, 51.0); + assert_eq!(plan.vias[0].drill_mm, 0.3); + } + + #[test] + fn freerouting_owned_ses_corpus_parses() { + let source = include_str!("../tests/fixtures/freerouting_issue368_no_gui_v2_3_0.ses"); + let root = parse_sexp(source).unwrap(); + require_head(&root, "session").unwrap(); + assert_eq!( + atom(&root, 1, "session id").unwrap(), + "corney_island_wireless" + ); + assert_eq!(one_child(&root, "routes").unwrap().head(), Some("routes")); + } + + #[test] + fn stale_board_revision_is_refused() { + let (_dir, board, mut source, manifest) = fixture(); + source.push(' '); + let error = parse_import_plan(&board, &source, &manifest, sample_ses()) + .unwrap_err() + .to_string(); + assert!(error.contains("revision"), "{error}"); + } + + #[test] + fn placement_change_is_refused() { + let (_dir, board, source, manifest) = fixture(); + let ses = sample_ses().replace("1000000 -500000", "1001000 -500000"); + let error = parse_import_plan(&board, &source, &manifest, &ses) + .unwrap_err() + .to_string(); + assert!(error.contains("changes placement"), "{error}"); + } + + #[test] + fn quarter_arc_lowers_to_kicad_start_mid_end() { + let (_dir, board, source, manifest) = fixture(); + let ses = sample_ses().replace( + "(wire (path F.Cu 2500", + "(wire (qarc F.Cu 2500 995000 -500000 1000000 -495000 1000000 -500000)) (wire (path F.Cu 2500", + ); + let plan = parse_import_plan(&board, &source, &manifest, &ses).unwrap(); + assert_eq!(plan.arcs.len(), 1); + assert_eq!(plan.arcs[0].mid_x_mm, 99.64644660940672); + assert_eq!(plan.arcs[0].mid_y_mm, 49.64644660940672); + } + + #[test] + fn mixed_path_and_qarc_wire_is_refused() { + let (_dir, board, source, manifest) = fixture(); + let ses = sample_ses().replace( + "(wire (path F.Cu 2500", + "(wire (qarc F.Cu 2500 995000 -500000 1000000 -495000 1000000 -500000) (path F.Cu 2500", + ); + let error = parse_import_plan(&board, &source, &manifest, &ses) + .unwrap_err() + .to_string(); + assert!(error.contains("exactly one path or qarc"), "{error}"); + } +} diff --git a/crates/konnect-core/src/tools/integration.rs b/crates/konnect-core/src/tools/integration.rs index c7e1580f..0fb9f70f 100644 --- a/crates/konnect-core/src/tools/integration.rs +++ b/crates/konnect-core/src/tools/integration.rs @@ -140,6 +140,24 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_check_freerouting(args, ctx).await } ), + tool!( + "route_specctra_dsn", + "Route an existing Specctra DSN through the local Freerouting JAR's native headless MCP server and create a new SES file. Uses the documented session/job state machine, never sends board data to a cloud service, and never replaces an existing output.", + json!({ + "type": "object", + "properties": { + "dsn_path": { "type": "string", "description": "Existing Specctra .dsn input" }, + "ses_output_path": { "type": "string", "description": "New .ses output path; existing files are never replaced" }, + "jar_path": { "type": "string", "description": "Optional Freerouting JAR path; otherwise uses installation discovery" }, + "max_passes": { "type": "integer", "minimum": 1, "maximum": 100 }, + "optimizer_enabled": { "type": "boolean" }, + "job_timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, + "overall_timeout_seconds": { "type": "integer", "minimum": 10, "maximum": 86400, "default": 900 } + }, + "required": ["dsn_path", "ses_output_path"] + }), + |args, ctx| async move { handle_route_specctra_dsn(args, ctx).await } + ), ] } @@ -1225,6 +1243,64 @@ async fn handle_check_freerouting( } } +async fn handle_route_specctra_dsn( + args: &serde_json::Value, + _ctx: &ToolContext, +) -> anyhow::Result { + let dsn = get_path(args, "dsn_path")?; + let ses_output = get_path(args, "ses_output_path")?; + let Some(jar) = find_freerouting_jar(args) else { + return Ok(CallToolResult::error( + "Freerouting JAR not found; install Freerouting or pass jar_path", + )); + }; + let max_passes = args["max_passes"] + .as_u64() + .map(u32::try_from) + .transpose() + .map_err(|_| anyhow::anyhow!("max_passes is too large"))?; + let job_timeout_seconds = args["job_timeout_seconds"].as_u64(); + let overall_timeout_seconds = args["overall_timeout_seconds"].as_u64().unwrap_or(900); + let settings = crate::freerouting_mcp::RouteSettings { + max_passes, + optimizer_enabled: args["optimizer_enabled"].as_bool(), + job_timeout_seconds, + poll_interval: std::time::Duration::from_secs(3), + overall_timeout: std::time::Duration::from_secs(overall_timeout_seconds), + }; + let evidence = crate::freerouting_mcp::route_local(&jar, &dsn, &ses_output, &settings) + .await + .map_err(|error| anyhow::anyhow!("Freerouting MCP routing failed: {error:#}"))?; + Ok(CallToolResult::json(&json!({ + "success": true, + "method": "local_freerouting_native_mcp", + "engine": { + "name": "Freerouting", + "jar_path": jar, + "execution": "local" + }, + "native_mcp": { + "used": true, + "server_protocol_version": evidence.server_protocol_version + }, + "bridge": { + "mode": "dsn_ses_file_round_trip", + "cloud_used": false + }, + "artifacts": { + "dsn_path": dsn, + "ses_output_path": ses_output, + "diagnostics_path": evidence.diagnostics_path + }, + "session_id": evidence.session_id, + "job_id": evidence.job_id, + "final_state": evidence.final_state, + "poll_count": evidence.poll_count, + "elapsed_seconds": evidence.elapsed_seconds, + "ses_bytes": evidence.ses_bytes + }))) +} + #[cfg(test)] mod freerouting_tests { use super::*; diff --git a/crates/konnect-core/src/tools/pcb_export.rs b/crates/konnect-core/src/tools/pcb_export.rs index 322cc955..36c186ad 100644 --- a/crates/konnect-core/src/tools/pcb_export.rs +++ b/crates/konnect-core/src/tools/pcb_export.rs @@ -1,14 +1,16 @@ //! `pcb_export` toolset — Gerber, PDF, SVG, 3D, BOM, netlist, position file, DRC, //! zone refill, and DXF/GenCAD/IPC-2581/ODB++ interchange formats. //! -//! All operations delegate to `kicad-cli` via the `cli` module, except `refill_zones` -//! which uses the KiCAD IPC API. +//! Most operations delegate to `kicad-cli` via the `cli` module. `refill_zones` +//! and revision-bound Specctra export use the KiCad IPC API. +use crate::mcp::error::ToolErrorKind; use crate::mcp::protocol::CallToolResult; use crate::tool; use crate::tools::{get_path, require_array, ToolContext, ToolDef}; use anyhow::Context; use serde_json::json; +use std::path::{Path, PathBuf}; use tokio::task; use super::cli; @@ -262,6 +264,45 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_export_netlist(args, ctx).await } ), + tool!( + "export_specctra_dsn", + "Export a deterministic Specctra DSN routing job from the exact live KiCad PCB \ + editor revision. Requires the named board to be open with IPC enabled. On KiCad 10, \ + an enabled authenticated ActionPlugin bridge supplies KiCad's native DSN while the \ + IPC snapshot and reverse manifest remain authoritative; otherwise the Rust exporter \ + is used. The first \ + supported profile is deliberately narrow: two copper layers, front-side SMD or \ + through-hole footprints, circle/rectangle pads, one straight-line closed outline, \ + and no existing tracks, vias, or zones. Konnect refuses unsupported geometry or \ + custom DRC rules, or incomplete effective routing rules instead of approximating \ + them, and writes a \ + revision-bound reverse manifest beside the DSN for later SES import.", + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Path to the .kicad_pcb file currently open in KiCad PCB Editor" + }, + "output": { + "type": "string", + "description": "New .dsn file path. Existing files are never replaced." + }, + "manifest_output_path": { + "type": "string", + "description": "Optional new reverse-manifest JSON path. Defaults to .konnect.json. Existing files are never replaced." + }, + "native_bridge_mode": { + "type": "string", + "enum": ["prefer", "require", "disable"], + "default": "prefer", + "description": "KiCad 10 native-export policy. 'prefer' uses the enabled authenticated ActionPlugin bridge with the Rust exporter as fallback; 'require' refuses fallback; 'disable' uses Rust only." + } + }, + "required": ["board", "output"] + }), + |args, ctx| async move { handle_export_specctra_dsn(args, ctx).await } + ), tool!( "export_position_file", "Generate a component placement (pick-and-place) position file for SMT assembly. \ @@ -605,6 +646,270 @@ async fn handle_export_netlist( )) } +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn default_specctra_manifest_path(output_path: &Path) -> PathBuf { + let mut name = output_path.as_os_str().to_os_string(); + name.push(".konnect.json"); + PathBuf::from(name) +} + +fn existing_export_targets(paths: &[&Path]) -> Vec { + paths + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect() +} + +fn write_specctra_export_pair( + output_path: &Path, + dsn: &str, + manifest_output_path: &Path, + manifest: &str, +) -> anyhow::Result<()> { + if let Some(parent) = output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("create DSN output directory {}", parent.display()))?; + } + if let Some(parent) = manifest_output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("create manifest output directory {}", parent.display()))?; + } + + // Both writes are create-new and individually atomic. If the second write + // loses a race, remove only the exact DSN this call just created so callers + // never receive half of a routing job. + konnect_sexp::write_new_atomic(output_path, dsn) + .with_context(|| format!("create DSN {}", output_path.display()))?; + if let Err(error) = konnect_sexp::write_new_atomic(manifest_output_path, manifest) { + let cleanup = std::fs::remove_file(output_path); + if let Err(cleanup_error) = cleanup { + return Err(anyhow::anyhow!(error)).context(format!( + "create manifest {} failed and cleanup of newly-created DSN {} also failed: {cleanup_error}", + manifest_output_path.display(), + output_path.display() + )); + } + return Err(anyhow::anyhow!(error)) + .with_context(|| format!("create manifest {}", manifest_output_path.display())); + } + Ok(()) +} + +async fn handle_export_specctra_dsn( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let board_path = get_path(args, "board")?; + let output_path = get_path(args, "output")?; + let manifest_output_path = args["manifest_output_path"] + .as_str() + .map(PathBuf::from) + .unwrap_or_else(|| default_specctra_manifest_path(&output_path)); + let native_bridge_mode = args["native_bridge_mode"].as_str().unwrap_or("prefer"); + + if !extension_is(&board_path, "kicad_pcb") { + return Ok(invalid_export_argument( + "board", + "must have the .kicad_pcb extension", + )); + } + if !extension_is(&output_path, "dsn") { + return Ok(invalid_export_argument( + "output", + "must have the .dsn extension", + )); + } + if output_path == manifest_output_path { + return Ok(invalid_export_argument( + "manifest_output_path", + "must not name the DSN output path", + )); + } + if !matches!(native_bridge_mode, "prefer" | "require" | "disable") { + return Ok(invalid_export_argument( + "native_bridge_mode", + "must be 'prefer', 'require', or 'disable'", + )); + } + let conflicts = existing_export_targets(&[&output_path, &manifest_output_path]); + if !conflicts.is_empty() { + return Ok(CallToolResult::error_kind( + ToolErrorKind::Conflict { + paths: conflicts.clone(), + }, + format!( + "Specctra export is non-destructive; remove or choose new output paths: {}", + conflicts.join(", ") + ), + )); + } + let board_path = board_path + .canonicalize() + .with_context(|| format!("resolve board {}", board_path.display()))?; + let custom_rules_path = board_path.with_extension("kicad_dru"); + if custom_rules_path.is_file() { + return Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: format!( + "custom DRC rules are outside the first supported routing profile: {}", + custom_rules_path.display() + ), + }, + format!( + "Specctra export refused: custom DRC rules are outside the first supported routing profile ({})", + custom_rules_path.display() + ), + )); + } + let board_for_ipc = board_path.clone(); + let addr = ctx.config.ipc_address.clone(); + let export = with_ipc(addr, move |client| { + let document = client.find_open_board(&board_for_ipc)?; + let before = client.save_document_to_string_in(document.clone())?; + let rules = client.get_effective_routing_rules_in(document.clone())?; + let after = client.save_document_to_string_in(document)?; + if before != after { + anyhow::bail!( + "KiCad board changed while routing rules were captured; retry from a stable editor revision" + ); + } + let export = crate::specctra::export_dsn(&board_for_ipc, &before, &rules)?; + Ok((export, before)) + }) + .await?; + let (baseline, source_snapshot) = match export { + Ok(export) => export, + Err(reason) => { + return Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: reason.clone(), + }, + format!("Specctra export refused: {reason}"), + )); + } + }; + // Preserve the method string shipped with the Rust exporter so existing + // callers do not break when the optional native path is introduced. + let mut method = "kicad_ipc_snapshot"; + let mut bridge_pid = None; + let mut bridge_protocol_version = None; + let mut bridge_diagnostics = Vec::new(); + let export = if native_bridge_mode == "disable" { + baseline + } else { + let attempt = crate::native_specctra_bridge::try_export(&board_path).await; + bridge_diagnostics = attempt.diagnostics; + if let Some(native) = attempt.export { + let board_for_stability = board_path.clone(); + let addr = ctx.config.ipc_address.clone(); + let stable = with_ipc(addr, move |client| { + let document = client.find_open_board(&board_for_stability)?; + client.save_document_to_string_in(document) + }) + .await?; + let stable = match stable { + Ok(stable) => stable, + Err(reason) => { + return Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: reason.clone(), + }, + format!("Specctra export refused after native bridge call: {reason}"), + )); + } + }; + if stable != source_snapshot { + return Ok(CallToolResult::error_kind( + ToolErrorKind::Conflict { + paths: vec![board_path.display().to_string()], + }, + "KiCad board changed during native Specctra export; retry from a stable editor revision", + )); + } + match crate::specctra::adopt_native_dsn(baseline.clone(), native.dsn) { + Ok(adopted) => { + method = "kicad10_native_actionplugin"; + bridge_pid = Some(native.plugin_pid); + bridge_protocol_version = Some(native.protocol_version); + adopted + } + Err(error) if native_bridge_mode == "prefer" => { + bridge_diagnostics.push(format!( + "native DSN semantic validation failed; used Rust fallback: {error:#}" + )); + baseline + } + Err(error) => { + return Ok(CallToolResult::error(format!( + "KiCad native Specctra export was required but its semantic validation failed: {error:#}" + ))); + } + } + } else if native_bridge_mode == "require" { + return Ok(CallToolResult::error(format!( + "KiCad 10 native Specctra bridge was required but unavailable: {}", + bridge_diagnostics.join("; ") + ))); + } else { + baseline + } + }; + + let output_for_write = output_path.clone(); + let manifest_for_write = manifest_output_path.clone(); + let dsn_for_write = export.dsn.clone(); + let manifest_text_for_write = export.manifest.clone(); + task::spawn_blocking(move || { + write_specctra_export_pair( + &output_for_write, + &dsn_for_write, + &manifest_for_write, + &manifest_text_for_write, + ) + }) + .await + .map_err(|error| anyhow::anyhow!("Specctra output writer panicked: {error}"))??; + + Ok(CallToolResult::text( + json!({ + "success": true, + "method": method, + "native_bridge_mode": native_bridge_mode, + "native_bridge_pid": bridge_pid, + "native_bridge_protocol_version": bridge_protocol_version, + "native_bridge_diagnostics": bridge_diagnostics, + "board": board_path, + "output": output_path, + "manifest_output_path": manifest_output_path, + "source_sha256": export.source_sha256, + "component_count": export.component_count, + "pad_count": export.pad_count, + "net_count": export.net_count, + "routing_class_count": export.class_count, + "capabilities": { + "dsn_export_available": true, + "ses_import_available": false, + "freerouting_bridge_available": false, + "source_revision_bound": true, + "supported_profile": "two_layer_front_side_circle_rect_no_existing_routing_or_zones" + } + }) + .to_string(), + )) +} + async fn handle_export_position_file( args: &serde_json::Value, ctx: &ToolContext, @@ -872,6 +1177,76 @@ mod new_export_format_tests { ) } + #[test] + fn specctra_manifest_defaults_beside_dsn() { + assert_eq!( + default_specctra_manifest_path(Path::new("build/clock.dsn")), + PathBuf::from("build/clock.dsn.konnect.json") + ); + } + + #[test] + fn specctra_pair_writer_removes_partial_dsn_if_manifest_creation_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let dsn = dir.path().join("board.dsn"); + let manifest = dir.path().join("board.dsn.konnect.json"); + std::fs::write(&manifest, "owned by another process").expect("seed conflict"); + + let error = write_specctra_export_pair(&dsn, "(pcb board)", &manifest, "{}") + .expect_err("existing manifest must be preserved"); + + assert!(error.to_string().contains("create manifest")); + assert!(!dsn.exists(), "a failed pair must not leave a DSN behind"); + assert_eq!( + std::fs::read_to_string(manifest).unwrap(), + "owned by another process" + ); + } + + #[tokio::test] + async fn specctra_export_reports_existing_output_as_structured_conflict() { + let dir = tempfile::tempdir().expect("tempdir"); + let board = dir.path().join("board.kicad_pcb"); + let output = dir.path().join("board.dsn"); + std::fs::write(&board, "(kicad_pcb)").expect("board fixture"); + std::fs::write(&output, "do not replace").expect("seed conflict"); + + let result = + handle_export_specctra_dsn(&json!({ "board": board, "output": output }), &test_ctx()) + .await + .expect("conflict is an MCP result"); + + assert!(result.is_error); + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some("conflict") + ); + assert_eq!(std::fs::read_to_string(output).unwrap(), "do not replace"); + } + + #[tokio::test] + async fn specctra_export_refuses_custom_rule_file_before_ipc() { + let dir = tempfile::tempdir().expect("tempdir"); + let board = dir.path().join("board.kicad_pcb"); + let rules = dir.path().join("board.kicad_dru"); + let output = dir.path().join("board.dsn"); + std::fs::write(&board, "(kicad_pcb)").expect("board fixture"); + std::fs::write(&rules, "(rule custom)").expect("custom rules fixture"); + + let result = + handle_export_specctra_dsn(&json!({ "board": board, "output": output }), &test_ctx()) + .await + .expect("unsupported profile is an MCP result"); + + assert!(result.is_error); + assert!(!output.exists()); + let text = match result.content.first().unwrap() { + crate::mcp::protocol::ToolContent::Text { text } => text, + _ => panic!("expected text result"), + }; + assert!(text.contains("custom DRC rules"), "{text}"); + } + #[tokio::test] async fn export_dxf_missing_board_returns_error() { let ctx = test_ctx(); diff --git a/crates/konnect-core/src/tools/pcb_routing.rs b/crates/konnect-core/src/tools/pcb_routing.rs index 55df671a..bfa83625 100644 --- a/crates/konnect-core/src/tools/pcb_routing.rs +++ b/crates/konnect-core/src/tools/pcb_routing.rs @@ -3,13 +3,19 @@ //! Routing operations use the KiCAD IPC API; `add_net`, `create_netclass`, and //! `add_copper_pour` use S-expression file manipulation. +use crate::mcp::error::ToolErrorKind; use crate::mcp::protocol::CallToolResult; use crate::tool; use crate::tools::{ get_path, opt_f64, require_f64, require_str, with_board_ipc_classified, ToolContext, ToolDef, }; +use anyhow::Context; use konnect_sexp::writer::{apply_edits, write_atomic, SexpEdit}; use serde_json::json; +use std::collections::BTreeMap; +use std::path::Path; + +use super::cli; macro_rules! ipc { ($ctx:expr, $args:expr, |$c:ident| $body:expr) => {{ @@ -102,6 +108,37 @@ pub fn tools() -> Vec { |args, ctx| async move { handle_add_via(args, ctx).await } ) .with_board_access(crate::tools::BoardAccess::LiveOnly), + tool!( + "plan_specctra_ses_import", + "Validate a Freerouting Specctra SES against its revision-bound Konnect manifest and the exact live KiCad board. Returns every track and via that would be created; never mutates or saves the board.", + json!({ + "type": "object", + "properties": { + "board": { "type": "string", "description": "Open source .kicad_pcb used for the DSN export" }, + "ses_path": { "type": "string", "description": "Freerouting .ses result" }, + "manifest_path": { "type": "string", "description": "Konnect reverse manifest written with the DSN" } + }, + "required": ["board", "ses_path", "manifest_path"] + }), + |args, ctx| async move { handle_plan_specctra_ses_import(args, ctx).await } + ) + .with_board_access(crate::tools::BoardAccess::LiveOnly), + tool!( + "apply_specctra_ses", + "Apply a fully validated Freerouting SES to the exact live KiCad board as one undo transaction, without saving over the source. Creates a new candidate .kicad_pcb, proves IPC read-back counts, and runs KiCad DRC before committing.", + json!({ + "type": "object", + "properties": { + "board": { "type": "string", "description": "Open source .kicad_pcb used for the DSN export" }, + "ses_path": { "type": "string", "description": "Freerouting .ses result" }, + "manifest_path": { "type": "string", "description": "Konnect reverse manifest written with the DSN" }, + "candidate_output_path": { "type": "string", "description": "New .kicad_pcb path. Existing files are never replaced." } + }, + "required": ["board", "ses_path", "manifest_path", "candidate_output_path"] + }), + |args, ctx| async move { handle_apply_specctra_ses(args, ctx).await } + ) + .with_board_access(crate::tools::BoardAccess::LiveOnly), tool!( "add_copper_pour", "Alias of pcb_board's add_zone, kept for compatibility: identical arguments, \ @@ -497,6 +534,370 @@ async fn handle_add_via( )) } +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn invalid_specctra_argument(name: &str, reason: &str) -> CallToolResult { + CallToolResult::error_kind( + ToolErrorKind::InvalidArgument { + field: name.to_string(), + reason: reason.to_string(), + }, + format!("Invalid '{name}': {reason}"), + ) +} + +async fn read_specctra_inputs( + args: &serde_json::Value, +) -> anyhow::Result> { + let ses_path = get_path(args, "ses_path")?; + let manifest_path = get_path(args, "manifest_path")?; + if !extension_is(&ses_path, "ses") { + return Ok(Err(invalid_specctra_argument( + "ses_path", + "must have the .ses extension", + ))); + } + if !manifest_path.is_file() { + return Ok(Err(invalid_specctra_argument( + "manifest_path", + "must name an existing reverse-manifest JSON file", + ))); + } + let ses_source = tokio::fs::read_to_string(&ses_path).await?; + let manifest_source = tokio::fs::read_to_string(&manifest_path).await?; + Ok(Ok((ses_path, ses_source, manifest_source))) +} + +async fn handle_plan_specctra_ses_import( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let board = get_path(args, "board")?; + if !extension_is(&board, "kicad_pcb") { + return Ok(invalid_specctra_argument( + "board", + "must have the .kicad_pcb extension", + )); + } + let (_ses_path, ses_source, manifest_source) = match read_specctra_inputs(args).await? { + Ok(inputs) => inputs, + Err(error) => return Ok(error), + }; + let board = board + .canonicalize() + .with_context(|| format!("resolve board {}", board.display()))?; + let board_for_ipc = board.clone(); + let result = with_board_ipc_classified(ctx, &board, move |client| { + let document = client.find_open_board(&board_for_ipc)?; + let before = client.save_document_to_string_in(document.clone())?; + let plan = crate::specctra_ses::parse_import_plan( + &board_for_ipc, + &before, + &manifest_source, + &ses_source, + )?; + let after = client.save_document_to_string_in(document)?; + if before != after { + anyhow::bail!("KiCad board changed while the SES import was planned; retry from a stable editor revision"); + } + Ok(plan) + }) + .await?; + match result { + Ok(plan) => Ok(CallToolResult::json(&json!({ + "success": true, + "method": "strict_dry_run", + "board": board, + "source_sha256": plan.source_sha256, + "session_id": plan.session_id, + "track_count": plan.tracks.len(), + "arc_count": plan.arcs.len(), + "via_count": plan.vias.len(), + "tracks": plan.tracks, + "arcs": plan.arcs, + "vias": plan.vias, + "mutated": false + }))), + Err(failure) => { + let reason = failure.message().to_string(); + Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: reason.clone(), + }, + format!("Specctra SES import refused: {reason}"), + )) + } + } +} + +#[derive(Debug)] +struct ApplyEvidence { + source_sha256: String, + session_id: String, + track_count: usize, + arc_count: usize, + via_count: usize, + created_count: usize, + drc_violations: usize, + unconnected_items: usize, + schematic_parity_violations: usize, +} + +async fn handle_apply_specctra_ses( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let board = get_path(args, "board")?; + let candidate = get_path(args, "candidate_output_path")?; + if !extension_is(&board, "kicad_pcb") { + return Ok(invalid_specctra_argument( + "board", + "must have the .kicad_pcb extension", + )); + } + if !extension_is(&candidate, "kicad_pcb") { + return Ok(invalid_specctra_argument( + "candidate_output_path", + "must have the .kicad_pcb extension", + )); + } + let drc_output = candidate.with_extension("drc.json"); + let conflicts = [&candidate, &drc_output] + .into_iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect::>(); + if !conflicts.is_empty() { + return Ok(CallToolResult::error_kind( + ToolErrorKind::Conflict { + paths: conflicts.clone(), + }, + format!( + "Specctra import is non-destructive; candidate or DRC output already exists: {}", + conflicts.join(", ") + ), + )); + } + if let Some(parent) = candidate + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent).await?; + } + let (_ses_path, ses_source, manifest_source) = match read_specctra_inputs(args).await? { + Ok(inputs) => inputs, + Err(error) => return Ok(error), + }; + let board = board + .canonicalize() + .with_context(|| format!("resolve board {}", board.display()))?; + let board_for_ipc = board.clone(); + let candidate_for_ipc = candidate.clone(); + let drc_output_for_ipc = drc_output.clone(); + let cli_path = ctx.config.kicad_cli.clone(); + let runtime = tokio::runtime::Handle::current(); + + let result = with_board_ipc_classified(ctx, &board, move |client| { + let open_boards = client.get_open_board_paths()?; + if open_boards.len() != 1 { + anyhow::bail!( + "atomic SES import requires exactly one PCB open in KiCad, got {} ({})", + open_boards.len(), + open_boards + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ); + } + let document = client.find_open_board(&board_for_ipc)?; + let before = client.save_document_to_string_in(document.clone())?; + let plan = crate::specctra_ses::parse_import_plan( + &board_for_ipc, + &before, + &manifest_source, + &ses_source, + )?; + + use konnect_ipc::gen::kiapi::common::types::KiCadObjectType as ObjectType; + let existing_tracks = client.get_items_in(document.clone(), ObjectType::KotPcbTrace)?; + let existing_arcs = client.get_items_in(document.clone(), ObjectType::KotPcbArc)?; + let existing_vias = client.get_items_in(document.clone(), ObjectType::KotPcbVia)?; + if !existing_tracks.is_empty() || !existing_arcs.is_empty() || !existing_vias.is_empty() { + anyhow::bail!( + "live board contains routing even though the bound export profile did not" + ); + } + let net_codes = client + .get_nets_in(document.clone())? + .into_iter() + .map(|net| (net.name, net.netcode)) + .collect::>(); + let mut items = Vec::with_capacity(plan.tracks.len() + plan.arcs.len() + plan.vias.len()); + for track in &plan.tracks { + konnect_ipc::builders::try_layer_from_name(&track.layer)?; + let net_code = *net_codes + .get(&track.net_name) + .with_context(|| format!("live board has no net '{}'", track.net_name))?; + let item = konnect_ipc::builders::build_track( + &track.net_name, + net_code, + &track.layer, + track.width_mm, + track.x1_mm, + track.y1_mm, + track.x2_mm, + track.y2_mm, + ); + items.push(konnect_ipc::builders::pack_any( + &item, + "kiapi.board.types.Track", + )); + } + for arc in &plan.arcs { + konnect_ipc::builders::try_layer_from_name(&arc.layer)?; + let net_code = *net_codes + .get(&arc.net_name) + .with_context(|| format!("live board has no net '{}'", arc.net_name))?; + let item = konnect_ipc::builders::build_track_arc( + &arc.net_name, net_code, &arc.layer, arc.width_mm, + arc.start_x_mm, arc.start_y_mm, arc.mid_x_mm, arc.mid_y_mm, + arc.end_x_mm, arc.end_y_mm, + ); + items.push(konnect_ipc::builders::pack_any(&item, "kiapi.board.types.Arc")); + } + for via in &plan.vias { + let net_code = *net_codes + .get(&via.net_name) + .with_context(|| format!("live board has no net '{}'", via.net_name))?; + let item = konnect_ipc::builders::build_via( + &via.net_name, + net_code, + via.x_mm, + via.y_mm, + via.drill_mm, + via.size_mm, + ); + items.push(konnect_ipc::builders::pack_any( + &item, + "kiapi.board.types.Via", + )); + } + let stable = client.save_document_to_string_in(document.clone())?; + if stable != before { + anyhow::bail!("KiCad board changed while route items were prepared; retry from a stable editor revision"); + } + let expected_count = items.len(); + let mut candidate_created = false; + let operation = client.run_commit("Import Freerouting SES", |client| { + let created = client.create_items_in_returning(document.clone(), items)?; + if created.len() != expected_count { + anyhow::bail!( + "KiCad returned {} created items for {} planned route primitives", + created.len(), + expected_count + ); + } + let read_tracks = client.get_items_in(document.clone(), ObjectType::KotPcbTrace)?; + let read_arcs = client.get_items_in(document.clone(), ObjectType::KotPcbArc)?; + let read_vias = client.get_items_in(document.clone(), ObjectType::KotPcbVia)?; + if read_tracks.len() != plan.tracks.len() + || read_arcs.len() != plan.arcs.len() + || read_vias.len() != plan.vias.len() + { + anyhow::bail!( + "IPC read-back mismatch: planned {} tracks/{} arcs/{} vias, read {} tracks/{} arcs/{} vias", + plan.tracks.len(), plan.arcs.len(), plan.vias.len(), + read_tracks.len(), read_arcs.len(), read_vias.len() + ); + } + let candidate_source = client.save_document_to_string_in(document.clone())?; + konnect_sexp::write_new_atomic(&candidate_for_ipc, &candidate_source) + .with_context(|| format!("create candidate {}", candidate_for_ipc.display()))?; + candidate_created = true; + let drc = runtime.block_on(cli::run_drc(&cli_path, &candidate_for_ipc, false))?; + let parity_count = drc.schematic_parity.as_ref().map_or(0, Vec::len); + if !drc.violations.is_empty() || parity_count != 0 { + anyhow::bail!( + "candidate failed KiCad DRC with {} violation(s) and {} schematic-parity violation(s)", + drc.violations.len(), + parity_count + ); + } + Ok(ApplyEvidence { + source_sha256: plan.source_sha256.clone(), + session_id: plan.session_id.clone(), + track_count: read_tracks.len(), + arc_count: read_arcs.len(), + via_count: read_vias.len(), + created_count: created.len(), + drc_violations: drc.violations.len(), + unconnected_items: drc.unconnected_items.as_ref().map_or(0, Vec::len), + schematic_parity_violations: parity_count, + }) + }); + match operation { + Ok(evidence) => Ok(evidence), + Err(error) => { + if candidate_created { + std::fs::remove_file(&candidate_for_ipc).with_context(|| { + format!( + "SES import failed ({error}); also failed to remove candidate {}", + candidate_for_ipc.display() + ) + })?; + } + if drc_output_for_ipc.exists() { + std::fs::remove_file(&drc_output_for_ipc).with_context(|| { + format!( + "SES import failed ({error}); also failed to remove DRC output {}", + drc_output_for_ipc.display() + ) + })?; + } + Err(error) + } + } + }) + .await?; + + match result { + Ok(evidence) => Ok(CallToolResult::json(&json!({ + "success": true, + "method": "strict_atomic_kicad_ipc_import", + "board": board, + "candidate_output_path": candidate, + "source_overwritten": false, + "undo_description": "Import Freerouting SES", + "source_sha256": evidence.source_sha256, + "session_id": evidence.session_id, + "track_count": evidence.track_count, + "arc_count": evidence.arc_count, + "via_count": evidence.via_count, + "created_count": evidence.created_count, + "ipc_readback": "exact_count_match", + "drc": { + "violations": evidence.drc_violations, + "unconnected_items": evidence.unconnected_items, + "schematic_parity_violations": evidence.schematic_parity_violations + } + }))), + Err(failure) => { + let reason = failure.message().to_string(); + Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: reason.clone(), + }, + format!("Specctra SES import refused or rolled back: {reason}"), + )) + } + } +} + /// `add_copper_pour` is an alias of `add_zone`; both build the same zone /// through [`crate::tools::pcb_board::add_zone_impl`]. They were two /// near-identical copies that had already drifted (different `min_width` diff --git a/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md new file mode 100644 index 00000000..5a781e32 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md @@ -0,0 +1,10 @@ +# Freerouting DSN corpus fixture + +`freerouting_issue269_minimal_v2_3_0.dsn` is copied semantically unchanged +from `freerouting/freerouting` tag `v2.3.0`, path +`fixtures/Issue269-min_fr_test/min_fr_test.dsn`; repeated whitespace and +single-clause line breaks are compacted only to keep the fixture reviewable. + +It is an engine-owned parser corpus fixture used to ensure Konnect's Specctra +dependency accepts syntax emitted and maintained by Freerouting. Freerouting is +licensed under GPL-3.0; Konnect is AGPL-3.0. diff --git a/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn new file mode 100644 index 00000000..9dd4085a --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn @@ -0,0 +1,70 @@ +(pcb C:\freerouting_test1\min_fr_test\min_fr_test.dsn + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "KiCad's Pcbnew") + (host_version "8.0.1") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (layer In1.Cu (type power) (property (index 1))) + (layer In2.Cu (type power) (property (index 2))) + (layer B.Cu (type signal) (property (index 3))) + (boundary + (path pcb 0 179000 -101000 149000 -101000 149000 -79000 179000 -79000 + 179000 -101000) + ) + (plane GND (polygon In2.Cu 0 150000 -80000 178000 -80000 178000 -100000 150000 -100000 + 150000 -80000)) + (via "Via[0-3]_600:300_um") + (rule + (width 200) + (clearance 200) + (clearance 200 (type default_smd)) + (clearance 50 (type smd_smd)) + ) + ) + (placement + (component TestPoint:TestPoint_Pad_1.0x1.0mm + (place J4 162900.000000 -92000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J8 171000.000000 -90000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J7 159000.000000 -85000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J1 155000.000000 -85000.000000 front 0.000000 (PN Conn_01x01_Pin)) + ) + ) + (library + (image TestPoint:TestPoint_Pad_1.0x1.0mm + (outline (path signal 120 -700 700 700 700)) + (outline (path signal 120 -700 -700 -700 700)) + (outline (path signal 120 700 700 700 -700)) + (outline (path signal 120 700 -700 -700 -700)) + (outline (path signal 50 -1000 1000 -1000 -1000)) + (outline (path signal 50 -1000 1000 1000 1000)) + (outline (path signal 50 1000 -1000 -1000 -1000)) + (outline (path signal 50 1000 -1000 1000 1000)) + (pin Rect[T]Pad_1000x1000_um 1 0 0) + ) + (padstack Rect[T]Pad_1000x1000_um + (shape (rect F.Cu -500 -500 500 500)) + (attach off) + ) + (padstack "Via[0-3]_600:300_um" + (shape (circle F.Cu 600)) + (shape (circle In1.Cu 600)) + (shape (circle In2.Cu 600)) + (shape (circle B.Cu 600)) + (attach off) + ) + ) + (network + (net "Net-(J1-Pin_1)" (pins J8-1 J1-1)) + (net GND (pins J4-1 J7-1)) + (class kicad_default "" GND "Net-(J1-Pin_1)" + (circuit (use_via Via[0-3]_600:300_um)) + (rule (width 200) (clearance 200)) + ) + ) + (wiring) +) diff --git a/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.README.md b/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.README.md new file mode 100644 index 00000000..d3c3a9dc --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.README.md @@ -0,0 +1,11 @@ +# Freerouting SES interoperability fixture + +`freerouting_issue368_no_gui_v2_3_0.ses` is copied from Freerouting's public +`fixtures/Issue368-CorneyIslandWireless/corney_island_wireless (no-GUI).ses` +at tag `v2.3.0`. It is retained as an external syntax corpus so Konnect's SES +reader is tested against output owned by Freerouting rather than only synthetic +Konnect fixtures. + +Source: + +Freerouting is distributed under the GNU General Public License v3.0. diff --git a/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.ses b/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.ses new file mode 100644 index 00000000..10238e02 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.ses @@ -0,0 +1,35 @@ +(session corney_island_wireless + (base_design corney_island_wireless) + (placement + (resolution um 10) + (component ceoloide:switch_mx + (place S16 1950000 -976250 back 180) + (place S17 1950000 -786250 back 180) + ) + (component ceoloide:diode_tht_sod123 + (place D16 2024000 -963000 back 270) + (place D17 2024000 -773000 back 270) + ) + ) + (was_is) + (routes + (resolution um 10) + (parser + (host_cad "KiCad's Pcbnew") + (host_version 8.0.0) + ) + (library_out + (padstack "Via[0-1]_600:300_um" + (shape (circle F.Cu 6000 0 0)) + (shape (circle B.Cu 6000 0 0)) + (attach off) + ) + (padstack "Via[0-1]_600:300_um" + (shape (circle F.Cu 6000 0 0)) + (shape (circle B.Cu 6000 0 0)) + (attach off) + ) + ) + (network_out) + ) +) diff --git a/crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md b/crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md new file mode 100644 index 00000000..4ddfcb60 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md @@ -0,0 +1,21 @@ +# Specctra two-resistor board fixture + +`specctra_two_resistors.kicad_pcb` is a deliberately small two-layer board used +to test the first fail-closed Specctra export profile. It was derived from the +repository's existing PCB integration fixture, assigned stable test UUIDs and a +closed rectangular outline, then opened and re-saved by KiCad 10.0.5 with: + +```text +kicad-cli pcb upgrade --force specctra_two_resistors.kicad_pcb +``` + +That final KiCad-authored serialization is intentional. In particular, it +captures KiCad 10's direct `(net "NAME")` pad syntax rather than relying on a +hand-written approximation of the board format. + +`specctra_two_resistors.native-kicad-10.dsn` was exported from that board by +KiCad 10.0.5's real `pcbnew.ExportSpecctraDSN(board, path)` binding. Only the +environment-specific absolute output path in the root `(pcb ...)` identifier +was normalized to `board.dsn`; structure, placement, library, network, rules, +and wiring content remain KiCad-authored. It is the differential fixture for +the optional KiCad 10 ActionPlugin bridge. diff --git a/crates/konnect-core/tests/fixtures/specctra_two_resistors.kicad_pcb b/crates/konnect-core/tests/fixtures/specctra_two_resistors.kicad_pcb new file mode 100644 index 00000000..816b8475 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/specctra_two_resistors.kicad_pcb @@ -0,0 +1,257 @@ +(kicad_pcb + (version 20260206) + (generator "pcbnew") + (generator_version "10.0") + (general + (thickness 1.6) + (legacy_teardrops no) + ) + (paper "A4") + (title_block + (title "Test Board") + (date "2024-01-01") + (rev "1.0") + (company "Test Co") + ) + (layers + (0 "F.Cu" signal) + (2 "B.Cu" signal) + (9 "F.Adhes" user "F.Adhesive") + (11 "B.Adhes" user "B.Adhesive") + (13 "F.Paste" user) + (15 "B.Paste" user) + (5 "F.SilkS" user "F.Silkscreen") + (7 "B.SilkS" user "B.Silkscreen") + (1 "F.Mask" user) + (3 "B.Mask" user) + (25 "Edge.Cuts" user) + (27 "Margin" user) + (31 "F.CrtYd" user "F.Courtyard") + (29 "B.CrtYd" user "B.Courtyard") + ) + (setup + (pad_to_mask_clearance 0.05) + (allow_soldermask_bridges_in_footprints no) + (tenting + (front yes) + (back yes) + ) + (covering + (front no) + (back no) + ) + (plugging + (front no) + (back no) + ) + (capping no) + (filling no) + (pcbplotparams + (layerselection 0x00000000_00000000_55555555_5755f5ff) + (plot_on_all_layers_selection 0x00000000_00000000_00000000_00000000) + (disableapertmacros no) + (usegerberextensions no) + (usegerberattributes yes) + (usegerberadvancedattributes yes) + (creategerberjobfile yes) + (dashed_line_dash_ratio 12) + (dashed_line_gap_ratio 3) + (svgprecision 4) + (plotframeref no) + (mode 1) + (useauxorigin no) + (pdf_front_fp_property_popups yes) + (pdf_back_fp_property_popups yes) + (pdf_metadata yes) + (pdf_single_document no) + (dxfpolygonmode yes) + (dxfimperialunits yes) + (dxfusepcbnewfont yes) + (psnegative no) + (psa4output no) + (plot_black_and_white yes) + (sketchpadsonfab no) + (plotpadnumbers no) + (hidednponfab no) + (sketchdnponfab yes) + (crossoutdnponfab yes) + (subtractmaskfromsilk no) + (outputformat 1) + (mirror no) + (drillshape 1) + (scaleselection 1) + (outputdirectory "") + ) + ) + (footprint "Resistor_SMD:R_0402" + (layer "F.Cu") + (uuid "11111111-1111-4111-8111-111111111111") + (at 100 50) + (property "Reference" "R1" + (at 0 -1.5 0) + (layer "F.SilkS") + (uuid "0c60d4fc-5d01-457d-942e-5dc4c5d7f73c") + (effects + (font + (size 1 1) + (thickness 0.15) + ) + ) + ) + (property "Value" "10k" + (at 0 1.5 0) + (layer "F.Fab") + (uuid "330d0924-3a6c-4931-989b-6dda45421585") + (effects + (font + (size 1 1) + (thickness 0.15) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (layer "F.Fab") + (hide yes) + (uuid "39a6229d-eb58-4939-bad2-d0ff201f0750") + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "" + (at 0 0 0) + (layer "F.Fab") + (hide yes) + (uuid "764436b9-58d7-42ef-9e9a-11e88174a533") + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (duplicate_pad_numbers_are_jumpers no) + (pad "1" smd rect + (at -0.5 0) + (size 0.6 0.5) + (layers "F.Cu" "F.Mask" "F.Paste") + (net "VCC") + (uuid "fb481b08-0972-41e5-a5d9-a4bab9f165c1") + ) + (pad "2" smd rect + (at 0.5 0) + (size 0.6 0.5) + (layers "F.Cu" "F.Mask" "F.Paste") + (net "GND") + (uuid "80cbeaee-444b-4a13-95c2-2c277db1ac17") + ) + (embedded_fonts no) + ) + (footprint "Resistor_SMD:R_0402" + (layer "F.Cu") + (uuid "22222222-2222-4222-8222-222222222222") + (at 110 50) + (property "Reference" "R2" + (at 0 -1.5 0) + (layer "F.SilkS") + (uuid "be2dcc12-71f8-4040-aeb6-d692cc821f6a") + (effects + (font + (size 1 1) + (thickness 0.15) + ) + ) + ) + (property "Value" "4.7k" + (at 0 1.5 0) + (layer "F.Fab") + (uuid "4d4da4d3-8e8a-4449-9f17-1776fc32f226") + (effects + (font + (size 1 1) + (thickness 0.15) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (layer "F.Fab") + (hide yes) + (uuid "c841b0c2-7eec-4bce-a5ee-32f302f9e018") + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "" + (at 0 0 0) + (layer "F.Fab") + (hide yes) + (uuid "fcbdc275-d4f9-49f0-b054-feec209526e4") + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (duplicate_pad_numbers_are_jumpers no) + (pad "1" smd rect + (at -0.5 0) + (size 0.6 0.5) + (layers "F.Cu" "F.Mask" "F.Paste") + (net "GND") + (uuid "611a5e55-7fda-4eb1-9177-2b7af8a58dba") + ) + (pad "2" smd rect + (at 0.5 0) + (size 0.6 0.5) + (layers "F.Cu" "F.Mask" "F.Paste") + (net "VCC") + (uuid "10e20b3d-431e-4a39-a976-77389855efa1") + ) + (embedded_fonts no) + ) + (gr_line + (start 80 30) + (end 130 30) + (stroke + (width 0.05) + (type default) + ) + (layer "Edge.Cuts") + (uuid "33333333-3333-4333-8333-333333333331") + ) + (gr_line + (start 80 70) + (end 80 30) + (stroke + (width 0.05) + (type default) + ) + (layer "Edge.Cuts") + (uuid "33333333-3333-4333-8333-333333333334") + ) + (gr_line + (start 130 30) + (end 130 70) + (stroke + (width 0.05) + (type default) + ) + (layer "Edge.Cuts") + (uuid "33333333-3333-4333-8333-333333333332") + ) + (gr_line + (start 130 70) + (end 80 70) + (stroke + (width 0.05) + (type default) + ) + (layer "Edge.Cuts") + (uuid "33333333-3333-4333-8333-333333333333") + ) + (embedded_fonts no) +) diff --git a/crates/konnect-core/tests/fixtures/specctra_two_resistors.native-kicad-10.dsn b/crates/konnect-core/tests/fixtures/specctra_two_resistors.native-kicad-10.dsn new file mode 100644 index 00000000..f3ee3229 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/specctra_two_resistors.native-kicad-10.dsn @@ -0,0 +1,74 @@ +(pcb board.dsn + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "KiCad's Pcbnew") + (host_version "10.0.5") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type signal) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 130000 -70000 80000 -70000 80000 -30000 130000 -30000 + 130000 -70000) + ) + (via "Via[0-1]_600:300_um") + (rule + (width 200) + (clearance 200) + (clearance 50 (type smd_smd)) + ) + ) + (placement + (component Resistor_SMD:R_0402 + (place R1 100000 -50000 front 0 (PN 10k)) + (place R2 110000 -50000 front 0 (PN 4.7k)) + ) + ) + (library + (image Resistor_SMD:R_0402 + (pin Rect[T]Pad_600.000000x500.000000_um 1 -500 0) + (pin Rect[T]Pad_600.000000x500.000000_um 2 500 0) + ) + (padstack Rect[T]Pad_600.000000x500.000000_um + (shape (rect F.Cu -300 -250 300 250)) + (attach off) + ) + (padstack "Via[0-1]_600:300_um" + (shape (circle F.Cu 600)) + (shape (circle B.Cu 600)) + (attach off) + ) + ) + (network + (net VCC + (pins R1-1 R2-2) + ) + (net GND + (pins R1-2 R2-1) + ) + (class kicad_default GND VCC + (circuit + (use_via "Via[0-1]_600:300_um") + ) + (rule + (width 200) + (clearance 200) + ) + ) + ) + (wiring + ) +) diff --git a/crates/konnect-ipc/src/builders.rs b/crates/konnect-ipc/src/builders.rs index e20a6ffb..cb09ba46 100644 --- a/crates/konnect-ipc/src/builders.rs +++ b/crates/konnect-ipc/src/builders.rs @@ -213,6 +213,32 @@ pub fn build_track( } } +/// Build an arc-shaped copper track from KiCad's native start/mid/end form. +#[allow(clippy::too_many_arguments)] +pub fn build_track_arc( + net_name: &str, + net_code: i32, + layer: &str, + width_mm: f64, + start_x: f64, + start_y: f64, + mid_x: f64, + mid_y: f64, + end_x: f64, + end_y: f64, +) -> kiapi::board::types::Arc { + kiapi::board::types::Arc { + id: None, + start: Some(vec2(start_x, start_y)), + mid: Some(vec2(mid_x, mid_y)), + end: Some(vec2(end_x, end_y)), + width: Some(distance(width_mm)), + locked: kiapi::common::types::LockedState::LsUnlocked as i32, + layer: layer_from_name(layer) as i32, + net: Some(net(net_name, net_code)), + } +} + /// Build a through-via `Via` protobuf message (F.Cu → B.Cu). /// /// Mirrors [`build_track`]: the caller `pack_any`s the result and hands it to @@ -939,6 +965,17 @@ pub(crate) mod tests { assert!(message.contains("Not.A.Layer"), "{message}"); } + #[test] + fn track_arc_carries_route_geometry_layer_width_and_net() { + let arc = build_track_arc("GND", 3, "B.Cu", 0.25, 10.0, 20.0, 11.0, 21.0, 12.0, 20.0); + assert_eq!(arc.start.unwrap().x_nm, 10_000_000); + assert_eq!(arc.mid.unwrap().y_nm, 21_000_000); + assert_eq!(arc.end.unwrap().x_nm, 12_000_000); + assert_eq!(arc.width.unwrap().value_nm, 250_000); + assert_eq!(arc.layer, kiapi::board::types::BoardLayer::BlBCu as i32); + assert_eq!(arc.net.unwrap().name, "GND"); + } + #[test] fn segment_populates_start_end_and_layer() { let s = board_segment("Edge.Cuts", 0.05, 1.0, 2.0, 3.0, 4.0); diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index 267ad81d..f8ee6e16 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -574,6 +574,32 @@ impl KiCadIpcClient { self.find_open_board(requested).map(|_| ()) } + /// Serialize the first open PCB through KiCad itself. + /// + /// This is a read-only IPC snapshot. It deliberately does not read the + /// on-disk file, which may lag behind unsaved editor state. + pub fn save_document_to_string(&self) -> Result { + self.save_document_to_string_in(self.get_board_document()?) + } + + /// As [`Self::save_document_to_string`], targeting one proven-open board. + pub fn save_document_to_string_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result { + let command = kiapi::common::commands::SaveDocumentToString { + document: Some(document), + }; + let response = unpack_required::( + self.send_command(&command, "kiapi.common.commands.SaveDocumentToString")?, + "SaveDocumentToString", + )?; + if response.contents.is_empty() { + anyhow::bail!("KiCad returned an empty PCB snapshot"); + } + Ok(response.contents) + } + /// Get all nets on the board. pub fn get_nets(&self) -> Result> { self.get_nets_in(self.get_board_document()?) @@ -601,6 +627,83 @@ impl KiCadIpcClient { } } + /// Return the effective merged routing rules for every connected net in + /// one open board. + /// + /// KiCad performs the class-priority merge. Konnect preserves missing + /// protobuf values as `None` so callers can fail closed. + pub fn get_effective_routing_rules_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result { + let nets = self.get_nets_in(document)?; + if nets.is_empty() { + return Ok(IpcEffectiveRoutingRules::new()); + } + let command = kiapi::board::commands::GetNetClassForNets { + net: nets + .iter() + .map(|net| kiapi::board::types::Net { + code: Some(kiapi::board::types::NetCode { value: net.netcode }), + name: net.name.clone(), + }) + .collect(), + }; + let response = unpack_required::( + self.send_command(&command, "kiapi.board.commands.GetNetClassForNets")?, + "GetNetClassForNets", + )?; + + let mut rules = IpcEffectiveRoutingRules::new(); + for net in nets.into_iter().filter(|net| !net.name.is_empty()) { + let class = response.classes.get(&net.name).with_context(|| { + format!( + "KiCad returned no effective netclass for net '{}'", + net.name + ) + })?; + let board = class.board.as_ref(); + let via_stack = board.and_then(|settings| settings.via_stack.as_ref()); + let via_diameter_mm = via_stack.and_then(|stack| { + stack + .copper_layers + .iter() + .filter_map(|layer| layer.size.as_ref()) + .map(|size| nm_to_mm(size.x_nm.max(size.y_nm))) + .filter(|diameter| diameter.is_finite() && *diameter > 0.0) + .reduce(f64::max) + }); + let via_drill_mm = via_stack + .and_then(|stack| stack.drill.as_ref()) + .and_then(|drill| drill.diameter.as_ref()) + .map(|diameter| nm_to_mm(diameter.x_nm.max(diameter.y_nm))) + .filter(|diameter| diameter.is_finite() && *diameter > 0.0); + let class_name = if class.name.is_empty() { + class.constituents.join("+") + } else { + class.name.clone() + }; + rules.insert( + net.name, + IpcRoutingRules { + class_name, + constituents: class.constituents.clone(), + track_width_mm: board + .and_then(|settings| settings.track_width.as_ref()) + .map(|distance| nm_to_mm(distance.value_nm)) + .filter(|value| value.is_finite() && *value > 0.0), + clearance_mm: board + .and_then(|settings| settings.clearance.as_ref()) + .map(|distance| nm_to_mm(distance.value_nm)) + .filter(|value| value.is_finite() && *value >= 0.0), + via_diameter_mm, + via_drill_mm, + }, + ); + } + Ok(rules) + } + /// Get board items by type. pub fn get_items( &self, diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index a1fb5d92..92dd5f05 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcVector2 { @@ -217,6 +218,24 @@ pub struct IpcGraphic { pub origin: Option, } +/// Effective PCB routing rules returned by KiCad for one net. +/// +/// Values are optional because KiCad's protobuf permits an incomplete class. +/// A routing exporter must refuse an incomplete rule set rather than inventing +/// manufacturing constraints. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IpcRoutingRules { + pub class_name: String, + pub constituents: Vec, + pub track_width_mm: Option, + pub clearance_mm: Option, + pub via_diameter_mm: Option, + pub via_drill_mm: Option, +} + +/// Net name to its effective (merged) KiCad routing rules. +pub type IpcEffectiveRoutingRules = BTreeMap; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcLayer { pub name: String, diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index 7b8e3178..00d5a3cc 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -111,6 +111,128 @@ fn open_board_response() -> kiapi::common::ApiResponse { )) } +#[test] +fn save_document_to_string_targets_the_named_open_board() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_board_response()); + } + if message.type_url.ends_with("SaveDocumentToString") { + let command = + kiapi::common::commands::SaveDocumentToString::decode(message.value.as_slice()) + .expect("decode SaveDocumentToString"); + let document = command.document.expect("target document"); + assert_eq!( + document.identifier, + Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "test.kicad_pcb".to_string() + ) + ) + ); + let response = kiapi::common::commands::SavedDocumentResponse { + document: Some(document), + contents: "(kicad_pcb (version 20260206))".to_string(), + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.SavedDocumentResponse", + ))); + } + panic!("unexpected command {}", message.type_url); + }); + + let client = KiCadIpcClient::new(&mock.url); + let document = client + .find_open_board(std::path::Path::new("test.kicad_pcb")) + .expect("the mock holds test.kicad_pcb"); + let snapshot = client + .save_document_to_string_in(document) + .expect("live board snapshot"); + assert_eq!(snapshot, "(kicad_pcb (version 20260206))"); +} + +#[test] +fn effective_routing_rules_preserve_complete_kicad_values() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_board_response()); + } + if message.type_url.ends_with("GetNets") { + let response = kiapi::board::commands::NetsResponse { + nets: vec![kiapi::board::types::Net { + code: Some(kiapi::board::types::NetCode { value: 7 }), + name: "GND".to_string(), + }], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.board.commands.NetsResponse", + ))); + } + if message.type_url.ends_with("GetNetClassForNets") { + let command = + kiapi::board::commands::GetNetClassForNets::decode(message.value.as_slice()) + .expect("decode GetNetClassForNets"); + assert_eq!(command.net.len(), 1); + assert_eq!(command.net[0].name, "GND"); + assert_eq!(command.net[0].code.as_ref().map(|code| code.value), Some(7)); + + let via_stack = kiapi::board::types::PadStack { + drill: Some(kiapi::board::types::DrillProperties { + diameter: Some(kiapi::common::types::Vector2 { + x_nm: 600_000, + y_nm: 600_000, + }), + ..Default::default() + }), + copper_layers: vec![kiapi::board::types::PadStackLayer { + size: Some(kiapi::common::types::Vector2 { + x_nm: 1_200_000, + y_nm: 1_200_000, + }), + ..Default::default() + }], + ..Default::default() + }; + let class = kiapi::common::project::NetClass { + name: "Default".to_string(), + board: Some(kiapi::common::project::NetClassBoardSettings { + clearance: Some(kiapi::common::types::Distance { value_nm: 200_000 }), + track_width: Some(kiapi::common::types::Distance { value_nm: 250_000 }), + via_stack: Some(via_stack), + ..Default::default() + }), + ..Default::default() + }; + let response = kiapi::board::commands::NetClassForNetsResponse { + classes: [("GND".to_string(), class)].into_iter().collect(), + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.board.commands.NetClassForNetsResponse", + ))); + } + panic!("unexpected command {}", message.type_url); + }); + + let client = KiCadIpcClient::new(&mock.url); + let document = client + .find_open_board(std::path::Path::new("test.kicad_pcb")) + .expect("the mock holds test.kicad_pcb"); + let rules = client + .get_effective_routing_rules_in(document) + .expect("effective rules"); + let gnd = rules.get("GND").expect("GND rules"); + assert_eq!(gnd.class_name, "Default"); + assert_eq!(gnd.track_width_mm, Some(0.25)); + assert_eq!(gnd.clearance_mm, Some(0.2)); + assert_eq!(gnd.via_diameter_mm, Some(1.2)); + assert_eq!(gnd.via_drill_mm, Some(0.6)); +} + #[test] fn ping_roundtrips_through_mock() { let mock = spawn_mock(|req| { diff --git a/crates/konnect/src/install.rs b/crates/konnect/src/install.rs index a1589410..74c819dd 100644 --- a/crates/konnect/src/install.rs +++ b/crates/konnect/src/install.rs @@ -756,6 +756,13 @@ mod tests { registered.get("route_trace"), Some(&konnect_core::tools::BoardAccess::LiveOnly) ); + for tool_name in ["plan_specctra_ses_import", "apply_specctra_ses"] { + assert_eq!( + registered.get(tool_name), + Some(&konnect_core::tools::BoardAccess::LiveOnly), + "{tool_name} must remain in the live-only Claude hook class" + ); + } assert_eq!( registered.get("place_component"), Some(&konnect_core::tools::BoardAccess::LivePreferredWithFallback) diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index 90c576ea..8a9cec95 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -60,10 +60,31 @@ schematic parity. `verification.rs`, `pcb_export.rs`, `design_review.rs`, and `manufacturing.rs` consume that complete result; unavailable categories or a failed CLI run cannot be treated as a clean board. -Freerouting remains usable through its KiCad ActionPlugin, but Konnect does not -currently have a safe DSN/SES bridge. The `autoroute` behavior in -`tools/integration.rs` reports that limitation instead of claiming KiCad removed -CLI commands that never existed. +Konnect's Freerouting bridge keeps the KiCad and routing responsibilities +separate: `export_specctra_dsn` snapshots the live board and writes a +revision-bound DSN job, `route_specctra_dsn` drives the discovered local JAR +through Freerouting's native headless MCP server, and +`plan_specctra_ses_import` / `apply_specctra_ses` validate and apply the result +through one KiCad undo transaction. Board data stays local, output files are +created without replacement, and the Freerouting child process is bounded and +owned by Konnect. + +On KiCad 10, `export_specctra_dsn` can optionally use the legacy Python +ActionPlugin as a deliberately narrow native-export bridge. The plugin calls +KiCad's own `pcbnew.ExportSpecctraDSN` on the UI thread and returns a +plugin-owned temporary file over an authenticated loopback endpoint. Rust +still captures the immutable IPC snapshot, rejects a board revision change, +checks that the native DSN has the same components, pads, nets, layers, and +routing rules, and writes the revision-bound reverse manifest. The temporary +file is consumed and deleted. + +The `native_bridge_mode` tool argument controls selection: `prefer` (the default) +uses a running bridge and otherwise falls back to the Rust DSN exporter, +`require` fails if native export cannot be used, and `disable` uses Rust only. +Native export is disabled in the plugin settings by default. This bridge is a +KiCad 10 compatibility path, not a substitute for the executable IPC plugin or +the KiCad 11 architecture; strict SES planning and atomic IPC apply never pass +through Python. ## Configuration @@ -82,8 +103,9 @@ CLI commands that never existed. ## Plugin, Viewer, And Packaging -`plugin` is a thin Python KiCad integration layer that launches/configures the -Rust server included in a PCM bundle. The standalone viewer in +`plugin` contains the legacy KiCad 10 Python ActionPlugin for settings/server +control and the optional native Specctra bridge. `plugin.json` declares the +separate executable IPC integration that is the forward path. The standalone viewer in `crates/schematic-viewer` watches schematic files and renders through `kicad-cli`; it is built and tested separately from the Rust workspace. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bcc35b6d..7c676d4f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -129,6 +129,32 @@ Common install paths are auto-detected (including the Windows registry). If your install is somewhere unusual, set the path in the plugin settings dialog in a `settings.json` beside the binary, or in a `konnect.toml` in the working directory (`kicad_cli`). Discovery order is `konnect.toml` and `settings.json` in the CWD, then `settings.json` next to the binary and one level up, then the platform config dir. A file under any other name is only read when passed with `--config`. +## Native Specctra export used the Rust fallback + +The KiCad-native exporter is an optional KiCad 10 compatibility path. Open the +PCB Editor, choose **Tools → External Plugins → Konnect**, enable **KiCad 10 +native Specctra bridge**, save, and close the dialog. The status changes to +running after the setting is applied. The requested board must be saved and be +the active PCB Editor document. + +`export_specctra_dsn` defaults to `native_bridge_mode: "prefer"`. Its response says +whether `method` was `kicad10_native_actionplugin` or `kicad_ipc_snapshot` and +includes bounded `native_bridge_diagnostics`. Use `native_bridge_mode: "require"` +when testing the native path so an unavailable bridge is an error instead of a +fallback. Use `disable` to force Rust output. + +The bridge listens only on an ephemeral IPv4 loopback port and requires a +per-session bearer token. Registration and temporary DSN files live under the +per-user local-data directory (`%LOCALAPPDATA%\konnect\native-bridge` on +Windows); `KONNECT_BRIDGE_DIR` overrides it for diagnostics and tests. A clean +plugin shutdown removes its own registration and temporary files. Stale +registrations left by a hard KiCad crash are ignored because Konnect probes and +authenticates each candidate before use. + +This option is unavailable on KiCad 11 after removal of the legacy SWIG Python +API. Konnect then uses its Rust exporter unless KiCad gains an equivalent +supported IPC operation. + ## Transaction recovery is blocked by divergent content Multi-file schematic changes persist a `.konnect-transaction-.json` @@ -206,7 +232,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 223 tools from the first call. +startup, so `tools/list` carries all 227 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/build-pcm.ps1 b/packaging/build-pcm.ps1 index 28c797e0..b0f4f138 100644 --- a/packaging/build-pcm.ps1 +++ b/packaging/build-pcm.ps1 @@ -8,6 +8,7 @@ # __init__.py ActionPlugin launcher (settings dialog, server control) # plugin.json KiCAD 10 IPC plugin manifest # settings_dialog.py wxPython settings UI +# native_bridge.py authenticated KiCad 10 native Specctra bridge # resources/icon.png toolbar icon (referenced by __init__.py) # bin/konnect.exe the MCP server binary # bin/schematic-viewer.exe (optional) live schematic viewer @@ -47,6 +48,7 @@ New-Item -ItemType Directory -Path "$staging/plugins/bin", "$staging/plugins/res Copy-Item "$repoRoot/plugin/__init__.py" "$staging/plugins/" Copy-Item "$repoRoot/plugin/plugin.json" "$staging/plugins/" Copy-Item "$repoRoot/plugin/settings_dialog.py" "$staging/plugins/" +Copy-Item "$repoRoot/plugin/native_bridge.py" "$staging/plugins/" Copy-Item $BinaryPath "$staging/plugins/bin/konnect.exe" if ($ViewerPath -and (Test-Path $ViewerPath)) { Copy-Item $ViewerPath "$staging/plugins/bin/schematic-viewer.exe" diff --git a/packaging/build-pcm.sh b/packaging/build-pcm.sh index 899c0a56..196474a2 100755 --- a/packaging/build-pcm.sh +++ b/packaging/build-pcm.sh @@ -9,6 +9,7 @@ # __init__.py ActionPlugin launcher (settings dialog, server control) # plugin.json KiCAD 10 IPC plugin manifest (entrypoint stamped) # settings_dialog.py wxPython settings UI +# native_bridge.py authenticated KiCad 10 native Specctra bridge # resources/icon.png toolbar icon (referenced by __init__.py) # bin/konnect the MCP server binary # bin/schematic-viewer (optional) live schematic viewer @@ -96,6 +97,7 @@ mkdir -p "$staging/plugins/bin" "$staging/plugins/resources" "$staging/resources cp "$repo_root/plugin/__init__.py" "$staging/plugins/" cp "$repo_root/plugin/plugin.json" "$staging/plugins/" cp "$repo_root/plugin/settings_dialog.py" "$staging/plugins/" +cp "$repo_root/plugin/native_bridge.py" "$staging/plugins/" cp "$binary" "$staging/plugins/bin/$bin_name" chmod +x "$staging/plugins/bin/$bin_name" if [ -n "$viewer" ] && [ -f "$viewer" ]; then diff --git a/packaging/metadata.json b/packaging/metadata.json index 0c005e81..e8f7c9a4 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,8 +1,8 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 217 tools organized into on-demand toolsets.", - "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, routing, library management, JLCPCB part search, Freerouting installation checks, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 20 toolsets loaded on demand so the AI only sees relevant tools at once.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 221 tools organized into on-demand toolsets.", + "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 20 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", "author": { diff --git a/packaging/validate-pcm.py b/packaging/validate-pcm.py index d53d7164..a504ce2a 100644 --- a/packaging/validate-pcm.py +++ b/packaging/validate-pcm.py @@ -32,6 +32,7 @@ "plugins/__init__.py", "plugins/plugin.json", "plugins/settings_dialog.py", + "plugins/native_bridge.py", "plugins/resources/icon.png", "resources/icon.png", ] diff --git a/plugin/__init__.py b/plugin/__init__.py index 431a2e91..376fd499 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -26,6 +26,7 @@ sys.path.insert(0, _plugin_dir) from settings_dialog import KonnectSettingsDialog, load_settings +from native_bridge import NativeSpecctraBridge import wx PLUGIN_DIR = _plugin_dir @@ -47,6 +48,23 @@ "konnect", "cache", ) _PID_FILE = os.path.join(_CACHE_DIR, "server.pid") +_native_bridge = NativeSpecctraBridge(pcbnew, wx) + + +def _sync_native_bridge(): + """Apply the persisted KiCad-10 bridge setting without exposing internals.""" + enabled = bool(load_settings(SETTINGS_PATH).get("native_specctra_bridge", False)) + if enabled: + try: + _native_bridge.start() + except Exception as error: + print(f"[Konnect] Native Specctra bridge failed to start: {error}", file=sys.stderr) + else: + _native_bridge.stop() + + +def _stop_native_bridge(): + _native_bridge.stop() def _stage(source_path): @@ -119,6 +137,7 @@ def _terminate_owned_server(): atexit.register(_terminate_owned_server) +atexit.register(_stop_native_bridge) def _kill_tracked(): @@ -246,6 +265,8 @@ def Run(self): plugin_dir=PLUGIN_DIR, binary_path=BINARY_PATH, server_running=is_server_running(), + native_bridge_available=_native_bridge.available(), + native_bridge_running=_native_bridge.running(), ) result = dlg.ShowModal() @@ -258,7 +279,9 @@ def Run(self): stop_server() dlg.Destroy() + _sync_native_bridge() # Register the plugin with KiCAD +_sync_native_bridge() KonnectPlugin().register() diff --git a/plugin/native_bridge.py b/plugin/native_bridge.py new file mode 100644 index 00000000..01da2aa6 --- /dev/null +++ b/plugin/native_bridge.py @@ -0,0 +1,283 @@ +"""Authenticated KiCad 10 native-operation bridge for Konnect. + +This module is loaded only by the legacy ``pcbnew.ActionPlugin``. It exposes +one deliberately narrow operation to the external Rust server: ask KiCad's +own SWIG binding to export the active board as Specctra DSN. It is not a +general Python execution service and is not the KiCad 11 integration path. +""" + +import json +import os +import secrets +import shutil +import tempfile +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +PROTOCOL_VERSION = 1 +MAX_REQUEST_BYTES = 16 * 1024 +MAX_DSN_BYTES = 512 * 1024 * 1024 +UI_TIMEOUT_SECONDS = 30 + + +def bridge_root(): + """Return the per-user registration directory shared with Konnect Rust.""" + override = os.environ.get("KONNECT_BRIDGE_DIR") + if override: + return os.path.abspath(os.path.expanduser(override)) + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) + elif sys_platform() == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) + return os.path.join(base, "konnect", "native-bridge") + + +def sys_platform(): + # Kept behind a function so platform-path behavior is easy to test. + import sys + + return sys.platform + + +def _canonical(path): + return os.path.normcase(os.path.realpath(os.path.abspath(path))) + + +def _write_owner_only_json(path, value): + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = f"{path}.{uuid.uuid4().hex}.tmp" + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + finally: + try: + os.remove(temporary) + except OSError: + pass + + +class _BridgeServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address, bridge): + self.bridge = bridge + super().__init__(address, _BridgeRequestHandler) + + +class _BridgeRequestHandler(BaseHTTPRequestHandler): + server_version = "KonnectNativeBridge/1" + + def log_message(self, _format, *_args): + # pcbnew has no reliable stderr and the bearer token must never enter logs. + return + + def _authorized(self): + expected = f"Bearer {self.server.bridge.token}" + return secrets.compare_digest(self.headers.get("Authorization", ""), expected) + + def _json(self, status, value): + payload = json.dumps(value, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload) + + def _require_auth(self): + if self._authorized(): + return True + self._json(401, {"success": False, "error": "unauthorized"}) + return False + + def do_GET(self): + if not self._require_auth(): + return + if self.path != "/v1/status": + self._json(404, {"success": False, "error": "unknown operation"}) + return + self._json(200, self.server.bridge.status()) + + def do_POST(self): + if not self._require_auth(): + return + if self.path != "/v1/export-specctra-dsn": + self._json(404, {"success": False, "error": "unknown operation"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._json(400, {"success": False, "error": "invalid content length"}) + return + if length <= 0 or length > MAX_REQUEST_BYTES: + self._json(413, {"success": False, "error": "request body is outside the supported size"}) + return + try: + request = json.loads(self.rfile.read(length).decode("utf-8")) + expected_board = request["expected_board"] + if not isinstance(expected_board, str) or not expected_board: + raise ValueError("expected_board must be a non-empty string") + result = self.server.bridge.export_specctra_dsn(expected_board) + except (KeyError, ValueError, json.JSONDecodeError) as error: + self._json(400, {"success": False, "error": str(error)}) + return + except Exception as error: # The response is bounded; traceback stays inside KiCad. + self._json(409, {"success": False, "error": str(error)[:2000]}) + return + self._json(200, result) + + +class NativeSpecctraBridge: + """Own one authenticated loopback server for one PCB Editor process.""" + + def __init__(self, pcbnew_module, wx_module, root=None): + self.pcbnew = pcbnew_module + self.wx = wx_module + self.root = root or bridge_root() + self.token = secrets.token_urlsafe(32) + self.server = None + self.thread = None + self.registration_path = os.path.join( + self.root, + f"bridge-{os.getpid()}-{secrets.token_hex(8)}.json", + ) + self.session_dir = None + self.started_at = None + self.operation_lock = threading.Lock() + + def available(self): + return callable(getattr(self.pcbnew, "ExportSpecctraDSN", None)) + + def running(self): + return self.server is not None and self.thread is not None and self.thread.is_alive() + + def start(self): + if self.running(): + return True + if not self.available(): + return False + os.makedirs(self.root, exist_ok=True) + self.session_dir = tempfile.mkdtemp(prefix=f"session-{os.getpid()}-", dir=self.root) + self.server = _BridgeServer(("127.0.0.1", 0), self) + self.started_at = time.time() + port = self.server.server_address[1] + registration = { + "protocol_version": PROTOCOL_VERSION, + "pid": os.getpid(), + "address": f"http://127.0.0.1:{port}", + "token": self.token, + "started_at_unix": self.started_at, + } + try: + _write_owner_only_json(self.registration_path, registration) + self.thread = threading.Thread( + target=self.server.serve_forever, + name="konnect-native-bridge", + daemon=True, + ) + self.thread.start() + except Exception: + self.stop() + raise + return True + + def stop(self): + server = self.server + thread = self.thread + self.server = None + self.thread = None + if server is not None: + # shutdown() waits for serve_forever(). Calling it when start() + # failed before the thread began would deadlock KiCad's UI thread. + if thread is not None and thread.is_alive(): + server.shutdown() + server.server_close() + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=5) + try: + os.remove(self.registration_path) + except OSError: + pass + if self.session_dir: + shutil.rmtree(self.session_dir, ignore_errors=True) + self.session_dir = None + + def status(self): + return { + "success": True, + "protocol_version": PROTOCOL_VERSION, + "pid": os.getpid(), + "native_specctra_export": self.available(), + } + + def export_specctra_dsn(self, expected_board): + if not self.running() or not self.session_dir: + raise RuntimeError("native bridge is not running") + if not self.operation_lock.acquire(blocking=False): + raise RuntimeError("native bridge is busy with another operation") + completed = threading.Event() + result = {} + + def run_on_ui_thread(): + try: + board = self.pcbnew.GetBoard() + board_path = board.GetFileName() if board is not None else "" + if not board_path: + raise RuntimeError("PCB Editor has no saved active board") + if _canonical(board_path) != _canonical(expected_board): + raise RuntimeError( + f"active KiCad board '{board_path}' does not match requested board '{expected_board}'" + ) + output = os.path.join(self.session_dir, f"native-{uuid.uuid4().hex}.dsn") + try: + ok = self.pcbnew.ExportSpecctraDSN(output) + except TypeError: + ok = self.pcbnew.ExportSpecctraDSN(board, output) + if not ok or not os.path.isfile(output): + raise RuntimeError("KiCad native Specctra export failed") + size = os.path.getsize(output) + if size <= 0 or size > MAX_DSN_BYTES: + try: + os.remove(output) + except OSError: + pass + raise RuntimeError(f"KiCad native DSN size {size} is outside the supported range") + result.update( + success=True, + protocol_version=PROTOCOL_VERSION, + pid=os.getpid(), + board_path=board_path, + dsn_path=output, + dsn_bytes=size, + ) + except Exception as error: + result["error"] = str(error) + finally: + completed.set() + self.operation_lock.release() + + try: + self.wx.CallAfter(run_on_ui_thread) + except Exception: + self.operation_lock.release() + raise + if not completed.wait(UI_TIMEOUT_SECONDS): + # The queued callback still owns the operation lock and releases it + # if/when KiCad's UI thread resumes. A second export must not race it. + raise RuntimeError("KiCad UI thread did not complete native Specctra export") + if "error" in result: + raise RuntimeError(result["error"]) + return result diff --git a/plugin/plugin.json b/plugin/plugin.json index 39b2252a..9cf62a70 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 217 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 221 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/plugin/settings_dialog.py b/plugin/settings_dialog.py index 844979f0..ca3fa1c2 100644 --- a/plugin/settings_dialog.py +++ b/plugin/settings_dialog.py @@ -23,6 +23,7 @@ "jlcpcb_db_path": "", "log_level": "info", "transport": "stdio", + "native_specctra_bridge": False, } LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] @@ -149,7 +150,15 @@ def detect_kicad_cli(): class KonnectSettingsDialog(wx.Dialog): """Settings dialog for the Konnect plugin.""" - def __init__(self, parent, plugin_dir, binary_path, server_running=False): + def __init__( + self, + parent, + plugin_dir, + binary_path, + server_running=False, + native_bridge_available=False, + native_bridge_running=False, + ): # No fixed pixel size: on high-DPI/scaled Windows displays a fixed # (520, 480) clipped the Save/Close buttons off-screen (issue #18). # The dialog is sized to fit its content after _build_ui instead. @@ -163,6 +172,8 @@ def __init__(self, parent, plugin_dir, binary_path, server_running=False): self.settings_path = os.path.join(plugin_dir, "settings.json") self.settings = load_settings(self.settings_path) self._server_running = server_running + self._native_bridge_available = native_bridge_available + self._native_bridge_running = native_bridge_running self._build_ui() self._populate_fields() @@ -217,6 +228,21 @@ def _build_ui(self): agrid.Add(self.transport_ctrl, 1, wx.EXPAND) adv_box.Add(agrid, 0, wx.EXPAND | wx.ALL, 5) + + self.native_bridge_ctrl = wx.CheckBox( + panel, + label="Enable KiCad 10 native Specctra bridge", + ) + self.native_bridge_ctrl.Enable(self._native_bridge_available) + adv_box.Add(self.native_bridge_ctrl, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) + bridge_state = "Running" if self._native_bridge_running else ( + "Available after Save/Close" if self._native_bridge_available else "Unavailable" + ) + self.native_bridge_status = wx.StaticText( + panel, + label=f"Native bridge: {bridge_state}", + ) + adv_box.Add(self.native_bridge_status, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) main_sizer.Add(adv_box, 0, wx.EXPAND | wx.ALL, 8) # ── Server status section ──────────────────────────────────── @@ -275,6 +301,11 @@ def _populate_fields(self): else: self.transport_ctrl.SetSelection(0) # "stdio" + self.native_bridge_ctrl.SetValue( + self._native_bridge_available + and bool(self.settings.get("native_specctra_bridge", False)) + ) + def _collect_settings(self): """Read current field values into a settings dict.""" return { @@ -283,6 +314,7 @@ def _collect_settings(self): "jlcpcb_db_path": self.jlcpcb_ctrl.GetValue().strip(), "log_level": LOG_LEVELS[self.log_level_ctrl.GetSelection()], "transport": TRANSPORTS[self.transport_ctrl.GetSelection()], + "native_specctra_bridge": self.native_bridge_ctrl.GetValue(), } def _update_server_status(self): diff --git a/plugin/tests/test_native_bridge.py b/plugin/tests/test_native_bridge.py new file mode 100644 index 00000000..0de57e9e --- /dev/null +++ b/plugin/tests/test_native_bridge.py @@ -0,0 +1,128 @@ +"""Tests for the narrowly-scoped KiCad 10 native Specctra bridge.""" + +import importlib.util +import json +import os +import tempfile +import types +import unittest +import urllib.error +import urllib.request + + +_PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SPEC = importlib.util.spec_from_file_location( + "konnect_native_bridge_under_test", + os.path.join(_PLUGIN_DIR, "native_bridge.py"), +) +native_bridge = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(native_bridge) + + +class _Board: + def __init__(self, path): + self.path = path + + def GetFileName(self): + return self.path + + +class _Pcbnew: + def __init__(self, board_path): + self.board = _Board(board_path) + + def GetBoard(self): + return self.board + + @staticmethod + def ExportSpecctraDSN(output): + with open(output, "w", encoding="utf-8") as stream: + stream.write("(pcb native-test)\n") + return True + + +class _Wx: + @staticmethod + def CallAfter(callback): + callback() + + +def _request(registration, path, body=None, token=None): + headers = {"Authorization": f"Bearer {token or registration['token']}"} + data = None + method = "GET" + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + method = "POST" + request = urllib.request.Request( + registration["address"] + path, + data=data, + headers=headers, + method=method, + ) + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.load(response) + + +class NativeBridgeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.board = os.path.join(self.temp.name, "board.kicad_pcb") + with open(self.board, "w", encoding="utf-8") as stream: + stream.write("(kicad_pcb)\n") + self.bridge = native_bridge.NativeSpecctraBridge( + _Pcbnew(self.board), + _Wx(), + root=os.path.join(self.temp.name, "bridge"), + ) + self.assertTrue(self.bridge.start()) + self.addCleanup(self.bridge.stop) + with open(self.bridge.registration_path, encoding="utf-8") as stream: + self.registration = json.load(stream) + + def test_status_and_export_require_the_registration_token(self): + status, payload = _request(self.registration, "/v1/status") + self.assertEqual(status, 200) + self.assertTrue(payload["native_specctra_export"]) + + with self.assertRaises(urllib.error.HTTPError) as caught: + _request(self.registration, "/v1/status", token="wrong") + self.assertEqual(caught.exception.code, 401) + + def test_export_uses_a_bridge_owned_path_and_reports_the_active_board(self): + status, payload = _request( + self.registration, + "/v1/export-specctra-dsn", + {"expected_board": self.board}, + ) + self.assertEqual(status, 200) + self.assertTrue(payload["success"]) + self.assertEqual(os.path.realpath(payload["board_path"]), os.path.realpath(self.board)) + self.assertTrue(os.path.isfile(payload["dsn_path"])) + self.assertEqual( + os.path.commonpath([payload["dsn_path"], self.bridge.session_dir]), + self.bridge.session_dir, + ) + self.assertGreater(payload["dsn_bytes"], 0) + + def test_export_refuses_a_different_board(self): + with self.assertRaises(urllib.error.HTTPError) as caught: + _request( + self.registration, + "/v1/export-specctra-dsn", + {"expected_board": os.path.join(self.temp.name, "other.kicad_pcb")}, + ) + self.assertEqual(caught.exception.code, 409) + + def test_stop_removes_registration_and_session_artifacts(self): + registration = self.bridge.registration_path + session = self.bridge.session_dir + self.bridge.stop() + self.assertFalse(os.path.exists(registration)) + self.assertFalse(os.path.exists(session)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tool-directory.md b/tool-directory.md index 46e8b455..a2a23957 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **20 toolsets** organized into 10 categories -- **217 registered tools** + **6 always-visible meta-tools** = **223 total** +- **221 registered tools** + **6 always-visible meta-tools** = **227 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -250,8 +250,8 @@ Six tools, grouped into *discovery/routing* and *observability*. | `duplicate_component` | Duplicate an existing footprint at a new position via KiCAD IPC. | | `get_board_2d_view` | Render the board with kicad-cli and return a base64 PNG. This is the 3-D render viewed from the top, not a layer plot, and takes no layer selection — use `export_svg` for layer-aware output. | -### `pcb_routing` · 13 tools -**Purpose:** Traces, vias, copper pours, net classes, differential pairs. +### `pcb_routing` · 15 tools +**Purpose:** Traces, vias, copper pours, net classes, differential pairs, and strict Specctra SES import. **Source:** [`crates/konnect-core/src/tools/pcb_routing.rs`](crates/konnect-core/src/tools/pcb_routing.rs) | Tool | Description | @@ -260,6 +260,8 @@ Six tools, grouped into *discovery/routing* and *observability*. | `route_trace` | Route a trace segment between two points on a copper layer via KiCAD IPC. | | `route_pad_to_pad` | Route a direct trace between two pads of named components (L-bend routing) via IPC. | | `add_via` | Add a through-hole via at a position and assign it to a net via IPC. | +| `plan_specctra_ses_import` | Strictly validate a Freerouting SES against its revision-bound manifest and the exact live board, returning every planned track and via without mutation. | +| `apply_specctra_ses` | Apply a validated SES through KiCad IPC as one undo transaction, create a separate candidate board, verify IPC read-back, and run KiCad DRC before commit. | | `add_copper_pour` | Alias of `add_zone`, kept for compatibility: same arguments, same defaults, same IPC-first behaviour. (Its `min_width` default was 0.25 and is now 0.2, matching `add_zone` and KiCad.) | | `delete_trace` | Delete a trace segment identified by its UUID via KiCAD IPC. | | `query_traces` | List trace segments on the board, optionally filtered by net and/or layer. | @@ -284,8 +286,8 @@ Six tools, grouped into *discovery/routing* and *observability*. --- -### `pcb_export` · 13 tools -**Purpose:** Gerber, PDF, SVG, 3D model, BOM, pick-and-place, DRC, DXF/GenCAD/IPC-2581/ODB++. +### `pcb_export` · 14 tools +**Purpose:** Gerber, PDF, SVG, 3D model, BOM, revision-bound Specctra DSN, pick-and-place, DRC, DXF/GenCAD/IPC-2581/ODB++. **Source:** [`crates/konnect-core/src/tools/pcb_export.rs`](crates/konnect-core/src/tools/pcb_export.rs) | Tool | Description | @@ -296,6 +298,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | `export_3d` | Export the PCB as a 3D model using kicad-cli, with explicit control over unspecified footprint models. | | `export_bom` | Generate KiCad 10's CSV Bill of Materials from schematic fields. | | `export_netlist` | Export the PCB netlist in KiCAD or IPC-D-356 format. | +| `export_specctra_dsn` | Export a deterministic, revision-bound Specctra DSN plus reverse manifest from a supported live KiCad board. On KiCad 10, `native_bridge_mode` can prefer or require the optional authenticated ActionPlugin native exporter; otherwise Konnect uses its Rust exporter. Refuses unsupported geometry or incomplete rules. | | `export_position_file` | Generate a component placement (pick-and-place) position file for SMT assembly. | | `export_dxf` | Export the PCB to DXF, one file per requested layer, using kicad-cli. `layers` is required — there is no all-layers default. For mechanical CAD interchange. | | `export_gencad` | Export the PCB in GenCAD format using kicad-cli. | @@ -336,8 +339,8 @@ Six tools, grouped into *discovery/routing* and *observability*. ## Integration -### `integration` · 8 tools -**Purpose:** JLCPCB parts database, Freerouting installation discovery, datasheet URLs. +### `integration` · 9 tools +**Purpose:** JLCPCB parts database, local Freerouting MCP routing, datasheet URLs. **Source:** [`crates/konnect-core/src/tools/integration.rs`](crates/konnect-core/src/tools/integration.rs) | Tool | Description | @@ -350,10 +353,12 @@ Six tools, grouped into *discovery/routing* and *observability*. | `enrich_datasheets` | Fetch and cache datasheet URLs for all components in a schematic (LCSC API). | | `get_datasheet_url` | Retrieve the datasheet URL for a component by MPN or LCSC ID — from the local JLCPCB catalog first, falling back to the LCSC API. | | `check_freerouting` | Locate a Freerouting installation, including KiCad PCM plugin directories, and verify that its Java runtime is available. | +| `route_specctra_dsn` | Route a DSN through the discovered local Freerouting JAR's native headless MCP server and create a new SES without cloud upload or replacement. | -Migration from the former `autoroute` tool: use Freerouting's KiCad ActionPlugin for -DSN/SES routing. Konnect no longer advertises `autoroute` because it had no editor -bridge and every call failed; `check_freerouting` remains available for diagnostics. +Migration from the former `autoroute` tool: use `export_specctra_dsn`, +`route_specctra_dsn`, then `plan_specctra_ses_import` / `apply_specctra_ses`. +Konnect delegates routing to Freerouting's native MCP server instead of duplicating +the router or relying on the KiCad ActionPlugin workflow. ---