From d0a6dc099ea6fbf88c7d691693146041b167361b Mon Sep 17 00:00:00 2001 From: neusse <6461221+neusse@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:45:35 -0700 Subject: [PATCH 1/5] feat(pcb): export revision-bound Specctra DSN --- Cargo.lock | 259 +++- Cargo.toml | 4 + DEV.md | 16 +- README.md | 4 +- THIRD_PARTY.md | 8 + crates/konnect-core/Cargo.toml | 1 + crates/konnect-core/src/lib.rs | 1 + crates/konnect-core/src/router/registry.rs | 4 +- crates/konnect-core/src/specctra.rs | 1069 +++++++++++++++++ crates/konnect-core/src/tools/pcb_export.rs | 286 ++++- .../fixtures/specctra_two_resistors.README.md | 14 + .../fixtures/specctra_two_resistors.kicad_pcb | 257 ++++ crates/konnect-ipc/src/client.rs | 103 ++ crates/konnect-ipc/src/types.rs | 19 + crates/konnect-ipc/tests/mock_server_test.rs | 122 ++ docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 7 +- 19 files changed, 2157 insertions(+), 23 deletions(-) create mode 100644 crates/konnect-core/src/specctra.rs create mode 100644 crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md create mode 100644 crates/konnect-core/tests/fixtures/specctra_two_resistors.kicad_pcb diff --git a/Cargo.lock b/Cargo.lock index 020f481f..4469a19c 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", ] @@ -2378,6 +2499,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "topola_specctra", "tracing", "usvg", "uuid", @@ -2478,6 +2600,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 +2783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2698,6 +2827,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 +3368,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 +3862,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 +4191,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 +4461,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..25f54592 100644 --- a/DEV.md +++ b/DEV.md @@ -95,7 +95,7 @@ Konnect/ │ │ ├── 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_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 @@ -303,7 +303,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 218 tools (224 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 +378,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, 218 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): 224 tools (218 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` now creates a revision-bound routing job from the live + editor, while end-to-end autorouting remains unavailable until strict SES + import and one-commit IPC application land. `check_freerouting` continues to + discover PCM installations and Java. diff --git a/README.md b/README.md index 14b146cf..44611366 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 +**218 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 218 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 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..49cc0cfa 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 diff --git a/crates/konnect-core/src/lib.rs b/crates/konnect-core/src/lib.rs index c566d56d..ddfe20d2 100644 --- a/crates/konnect-core/src/lib.rs +++ b/crates/konnect-core/src/lib.rs @@ -4,4 +4,5 @@ pub mod kicad_install; pub mod mcp; pub mod observability; pub mod router; +pub(crate) mod specctra; pub mod tools; diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index f78e5104..f972bd54 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -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", diff --git a/crates/konnect-core/src/specctra.rs b/crates/konnect-core/src/specctra.rs new file mode 100644 index 00000000..c6e983b9 --- /dev/null +++ b/crates/konnect-core/src/specctra.rs @@ -0,0 +1,1069 @@ +//! 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::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, 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, Serialize)] +struct Manifest<'a> { + schema_version: u32, + board_path: String, + source_sha256: &'a str, + coordinate_unit: &'static str, + resolution: u32, + supported_profile: SupportedProfile, + layers: Vec, + components: Vec, + nets: Vec, + padstacks: Vec, +} + +#[derive(Debug, Serialize)] +struct SupportedProfile { + copper_layers: u32, + component_side: &'static str, + pad_shapes: Vec<&'static str>, + existing_routing: bool, + copper_zones: bool, + custom_rules: bool, + outline: &'static str, +} + +#[derive(Debug, Serialize)] +struct ManifestLayer { + kicad_name: String, + dsn_name: String, + index: usize, +} + +#[derive(Debug, Serialize)] +struct ManifestComponent { + reference: String, + kiid: String, + image_name: String, + pads: Vec, +} + +#[derive(Debug, Serialize)] +struct ManifestPin { + pad_number: String, + dsn_pin: String, + net: Option, + padstack_name: String, +} + +#[derive(Debug, Serialize)] +struct ManifestNet { + name: String, + pins: Vec, + class_name: String, +} + +#[derive(Debug, Serialize)] +struct ManifestPadstack { + name: String, + purpose: &'static str, + 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(), + }) +} + +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(), + 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", + 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", + 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, + coordinate_unit: "um", + resolution: DSN_RESOLUTION as u32, + supported_profile: SupportedProfile { + copper_layers: 2, + component_side: "front", + pad_shapes: vec!["circle", "rect"], + existing_routing: false, + copper_zones: false, + custom_rules: false, + outline: "one closed loop of straight Edge.Cuts lines", + }, + 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() + } + + #[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")); + } + + /// 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 export = export_dsn(Path::new("board.kicad_pcb"), source, &rules()).unwrap(); + let temp = tempfile::tempdir().expect("tempdir"); + let dsn_path = temp.path().join("board.dsn"); + let ses_path = temp.path().join("board.ses"); + 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" + ); + } + + #[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/tools/pcb_export.rs b/crates/konnect-core/src/tools/pcb_export.rs index 322cc955..40f30c72 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,36 @@ 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. 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": { + "type": "string", + "description": "Optional new reverse-manifest JSON path. Defaults to .konnect.json. Existing files are never replaced." + } + }, + "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 +637,186 @@ 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"] + .as_str() + .map(PathBuf::from) + .unwrap_or_else(|| default_specctra_manifest_path(&output_path)); + + 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", + "must not name the DSN output path", + )); + } + 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" + ); + } + crate::specctra::export_dsn(&board_for_ipc, &before, &rules) + }) + .await?; + let export = match export { + Ok(export) => export, + Err(reason) => { + return Ok(CallToolResult::error_kind( + ToolErrorKind::HandlerError { + reason: reason.clone(), + }, + format!("Specctra export refused: {reason}"), + )); + } + }; + + 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": "kicad_ipc_snapshot", + "board": board_path, + "output": output_path, + "manifest_output": 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, + "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 +1084,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/tests/fixtures/specctra_two_resistors.README.md b/crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md new file mode 100644 index 00000000..d8624eb7 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/specctra_two_resistors.README.md @@ -0,0 +1,14 @@ +# 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. 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-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/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c54931c8..d5323174 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -193,7 +193,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 224 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/metadata.json b/packaging/metadata.json index 0c005e81..4c3ef3e3 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$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": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 218 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.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index 39b2252a..bced77d5 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. 218 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index 46e8b455..fbd954b1 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** +- **218 registered tools** + **6 always-visible meta-tools** = **224 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`. @@ -284,8 +284,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 +296,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; 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. | From 7fd46874cb1cdba78e7523f0f9a37e22bcaca030 Mon Sep 17 00:00:00 2001 From: neusse <6461221+neusse@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:50:40 -0700 Subject: [PATCH 2/5] feat(pcb): record placement in routing manifest --- crates/konnect-core/src/specctra.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/konnect-core/src/specctra.rs b/crates/konnect-core/src/specctra.rs index c6e983b9..6d208b37 100644 --- a/crates/konnect-core/src/specctra.rs +++ b/crates/konnect-core/src/specctra.rs @@ -116,6 +116,10 @@ struct ManifestComponent { reference: String, kiid: String, image_name: String, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + side: &'static str, pads: Vec, } @@ -786,6 +790,10 @@ fn build_manifest( 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", pads: footprint .pads .iter() @@ -975,6 +983,12 @@ mod tests { 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"); } /// Optional local parity check against the Freerouting engine. CI does not From 40cd6906d25a9c8bf36614ea1f01be19c2545a25 Mon Sep 17 00:00:00 2001 From: neusse <6461221+neusse@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:25:11 -0700 Subject: [PATCH 3/5] fix(pcb): align Specctra export contract --- crates/konnect-core/src/specctra.rs | 8 +++ crates/konnect-core/src/tools/pcb_export.rs | 16 +++-- ...erouting_issue269_minimal_v2_3_0.README.md | 10 +++ .../freerouting_issue269_minimal_v2_3_0.dsn | 70 +++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md create mode 100644 crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn diff --git a/crates/konnect-core/src/specctra.rs b/crates/konnect-core/src/specctra.rs index 6d208b37..d8e7294c 100644 --- a/crates/konnect-core/src/specctra.rs +++ b/crates/konnect-core/src/specctra.rs @@ -991,6 +991,14 @@ mod tests { 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"); + } + /// 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 diff --git a/crates/konnect-core/src/tools/pcb_export.rs b/crates/konnect-core/src/tools/pcb_export.rs index 40f30c72..47592de7 100644 --- a/crates/konnect-core/src/tools/pcb_export.rs +++ b/crates/konnect-core/src/tools/pcb_export.rs @@ -285,7 +285,7 @@ pub fn tools() -> Vec { "type": "string", "description": "New .dsn file path. Existing files are never replaced." }, - "manifest_output": { + "manifest_output_path": { "type": "string", "description": "Optional new reverse-manifest JSON path. Defaults to .konnect.json. Existing files are never replaced." } @@ -704,7 +704,7 @@ async fn handle_export_specctra_dsn( ) -> anyhow::Result { let board_path = get_path(args, "board")?; let output_path = get_path(args, "output")?; - let manifest_output_path = args["manifest_output"] + let manifest_output_path = args["manifest_output_path"] .as_str() .map(PathBuf::from) .unwrap_or_else(|| default_specctra_manifest_path(&output_path)); @@ -723,7 +723,7 @@ async fn handle_export_specctra_dsn( } if output_path == manifest_output_path { return Ok(invalid_export_argument( - "manifest_output", + "manifest_output_path", "must not name the DSN output path", )); } @@ -805,13 +805,19 @@ async fn handle_export_specctra_dsn( "method": "kicad_ipc_snapshot", "board": board_path, "output": output_path, - "manifest_output": manifest_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, - "supported_profile": "two_layer_front_side_circle_rect_no_existing_routing_or_zones" + "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(), )) 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) +) From 4d830729c704ee047ab3db63ef1aaa904f0d3f52 Mon Sep 17 00:00:00 2001 From: neusse <6461221+neusse@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:07:04 -0700 Subject: [PATCH 4/5] feat(pcb): import Freerouting SES atomically --- DEV.md | 8 +- README.md | 4 +- crates/konnect-core/src/lib.rs | 1 + crates/konnect-core/src/router/registry.rs | 4 +- crates/konnect-core/src/specctra.rs | 7 +- crates/konnect-core/src/specctra_ses.rs | 814 +++++++++++++++++++ crates/konnect-core/src/tools/pcb_routing.rs | 375 +++++++++ crates/konnect/src/manifest.rs | 2 +- docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 8 +- 12 files changed, 1213 insertions(+), 16 deletions(-) create mode 100644 crates/konnect-core/src/specctra_ses.rs diff --git a/DEV.md b/DEV.md index 25f54592..ddf93766 100644 --- a/DEV.md +++ b/DEV.md @@ -94,7 +94,7 @@ 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_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 @@ -303,7 +303,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 218 tools (224 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 220 tools (226 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,9 +378,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **20 toolsets, 218 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) +- **20 toolsets, 220 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): 224 tools (218 registered + 6 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 226 tools (220 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. diff --git a/README.md b/README.md index 44611366..b6c2637d 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). -**218 tools across 20 on-demand toolsets.** Schematic capture, PCB layout and +**220 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 218 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 220 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 diff --git a/crates/konnect-core/src/lib.rs b/crates/konnect-core/src/lib.rs index ddfe20d2..29f405dd 100644 --- a/crates/konnect-core/src/lib.rs +++ b/crates/konnect-core/src/lib.rs @@ -5,4 +5,5 @@ pub mod mcp; 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/router/registry.rs b/crates/konnect-core/src/router/registry.rs index f972bd54..11a737e8 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", diff --git a/crates/konnect-core/src/specctra.rs b/crates/konnect-core/src/specctra.rs index d8e7294c..afeed93b 100644 --- a/crates/konnect-core/src/specctra.rs +++ b/crates/konnect-core/src/specctra.rs @@ -1008,10 +1008,12 @@ mod tests { 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 export = export_dsn(Path::new("board.kicad_pcb"), source, &rules()).unwrap(); 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") @@ -1038,6 +1040,9 @@ mod tests { .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"); } #[test] diff --git a/crates/konnect-core/src/specctra_ses.rs b/crates/konnect-core/src/specctra_ses.rs new file mode 100644 index 00000000..e65fe32c --- /dev/null +++ b/crates/konnect-core/src/specctra_ses.rs @@ -0,0 +1,814 @@ +//! 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 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 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, vias) = parse_network( + one_child(routes, "network_out")?, + route_resolution, + &manifest, + )?; + if tracks.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, + 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)> { + 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 vias = Vec::new(); + let mut seen_tracks = 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"])?; + let path = one_child(wire, "path")?; + 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, + }); + } + } + 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, 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_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 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 unsupported_route_syntax_is_refused() { + let (_dir, board, source, manifest) = fixture(); + let ses = sample_ses().replace( + "(wire (path F.Cu 2500", + "(wire (qarc F.Cu 2500 0 0 1 1) (path F.Cu 2500", + ); + let error = parse_import_plan(&board, &source, &manifest, &ses) + .unwrap_err() + .to_string(); + assert!(error.contains("qarc"), "{error}"); + } +} diff --git a/crates/konnect-core/src/tools/pcb_routing.rs b/crates/konnect-core/src/tools/pcb_routing.rs index 09c18e81..c6958a57 100644 --- a/crates/konnect-core/src/tools/pcb_routing.rs +++ b/crates/konnect-core/src/tools/pcb_routing.rs @@ -3,12 +3,18 @@ //! 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, ToolContext, ToolDef}; +use anyhow::Context; use konnect_ipc::client::KiCadIpcClient; use konnect_sexp::writer::{apply_edits, write_atomic, SexpEdit}; use serde_json::json; +use std::collections::BTreeMap; +use std::path::Path; + +use super::cli; // ─── IPC helper ─────────────────────────────────────────────────────────────── @@ -118,6 +124,35 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_add_via(args, ctx).await } ), + 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": { "type": "string", "description": "Freerouting .ses result" }, + "manifest": { "type": "string", "description": "Konnect reverse manifest written with the DSN" } + }, + "required": ["board", "ses", "manifest"] + }), + |args, ctx| async move { handle_plan_specctra_ses_import(args, ctx).await } + ), + 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": { "type": "string", "description": "Freerouting .ses result" }, + "manifest": { "type": "string", "description": "Konnect reverse manifest written with the DSN" }, + "candidate_output": { "type": "string", "description": "New .kicad_pcb path. Existing files are never replaced." } + }, + "required": ["board", "ses", "manifest", "candidate_output"] + }), + |args, ctx| async move { handle_apply_specctra_ses(args, ctx).await } + ), tool!( "add_copper_pour", "Alias of pcb_board's add_zone, kept for compatibility: identical arguments, \ @@ -507,6 +542,346 @@ 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")?; + let manifest_path = get_path(args, "manifest")?; + if !extension_is(&ses_path, "ses") { + return Ok(Err(invalid_specctra_argument( + "ses", + "must have the .ses extension", + ))); + } + if !manifest_path.is_file() { + return Ok(Err(invalid_specctra_argument( + "manifest", + "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 addr = ctx.config.ipc_address.clone(); + let result = 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 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(), + "via_count": plan.vias.len(), + "tracks": plan.tracks, + "vias": plan.vias, + "mutated": false + }))), + Err(reason) => 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, + 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")?; + 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", + "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 addr = ctx.config.ipc_address.clone(); + + let result = with_ipc(addr, 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_vias = client.get_items_in(document.clone(), ObjectType::KotPcbVia)?; + if !existing_tracks.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.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 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_vias = client.get_items_in(document.clone(), ObjectType::KotPcbVia)?; + if read_tracks.len() != plan.tracks.len() || read_vias.len() != plan.vias.len() { + anyhow::bail!( + "IPC read-back mismatch: planned {} tracks/{} vias, read {} tracks/{} vias", + plan.tracks.len(), + plan.vias.len(), + read_tracks.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(), + 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": candidate, + "source_overwritten": false, + "undo_description": "Import Freerouting SES", + "source_sha256": evidence.source_sha256, + "session_id": evidence.session_id, + "track_count": evidence.track_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(reason) => 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/src/manifest.rs b/crates/konnect/src/manifest.rs index ac7f6245..7f1d0eaa 100644 --- a/crates/konnect/src/manifest.rs +++ b/crates/konnect/src/manifest.rs @@ -124,7 +124,7 @@ pub const HOOK_SKILLS: &[HookSkillManifest] = &[ content: "These PCB tools require KiCAD to be running with the board file open.\n\ If you get a connection error, tell the user: \"Please open KiCAD and load\n\ your .kicad_pcb file, then try again.\" Do not retry more than once.", - tool_matcher: "mcp__konnect__(place_component|move_component|rotate_component|route_trace|add_via|route_differential_pair|route_pad_to_pad|refill_zones)", + tool_matcher: "mcp__konnect__(place_component|move_component|rotate_component|route_trace|add_via|route_differential_pair|route_pad_to_pad|plan_specctra_ses_import|apply_specctra_ses|refill_zones)", event: "PreToolUse", }, ]; diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d5323174..d0c5a3a8 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -193,7 +193,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 224 tools from the first call. +startup, so `tools/list` carries all 226 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/metadata.json b/packaging/metadata.json index 4c3ef3e3..bf02e1cf 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$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 218 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 220 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.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index bced77d5..fa00c278 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. 218 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 220 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index fbd954b1..4cf7ef98 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 -- **218 registered tools** + **6 always-visible meta-tools** = **224 total** +- **220 registered tools** + **6 always-visible meta-tools** = **226 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. | From 435603be3fcaedc3068c4e914ae5d84ee576fc55 Mon Sep 17 00:00:00 2001 From: neusse <6461221+neusse@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:35:17 -0700 Subject: [PATCH 5/5] fix(pcb): complete strict SES import support --- crates/konnect-core/src/specctra_ses.rs | 235 +++++++++++++----- crates/konnect-core/src/tools/pcb_routing.rs | 64 +++-- ...eerouting_issue368_no_gui_v2_3_0.README.md | 11 + .../freerouting_issue368_no_gui_v2_3_0.ses | 35 +++ crates/konnect-ipc/src/builders.rs | 37 +++ 5 files changed, 303 insertions(+), 79 deletions(-) create mode 100644 crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.README.md create mode 100644 crates/konnect-core/tests/fixtures/freerouting_issue368_no_gui_v2_3_0.ses diff --git a/crates/konnect-core/src/specctra_ses.rs b/crates/konnect-core/src/specctra_ses.rs index e65fe32c..00b99e4f 100644 --- a/crates/konnect-core/src/specctra_ses.rs +++ b/crates/konnect-core/src/specctra_ses.rs @@ -19,6 +19,7 @@ pub(crate) struct SesImportPlan { pub source_sha256: String, pub session_id: String, pub tracks: Vec, + pub arcs: Vec, pub vias: Vec, } @@ -33,6 +34,19 @@ pub(crate) struct SesTrack { 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, @@ -165,12 +179,12 @@ pub(crate) fn parse_import_plan( route_resolution, &manifest, )?; - let (tracks, vias) = parse_network( + let (tracks, arcs, vias) = parse_network( one_child(routes, "network_out")?, route_resolution, &manifest, )?; - if tracks.is_empty() && vias.is_empty() { + if tracks.is_empty() && arcs.is_empty() && vias.is_empty() { bail!("SES contains no route primitives"); } @@ -179,6 +193,7 @@ pub(crate) fn parse_import_plan( source_sha256: manifest.source_sha256, session_id, tracks, + arcs, vias, }) } @@ -434,7 +449,7 @@ fn parse_network( node: &SexpNode, resolution: f64, manifest: &Manifest, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec, Vec)> { require_direct_shape(node, 0, &["net"])?; let nets = manifest .nets @@ -453,8 +468,10 @@ fn parse_network( .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") { @@ -464,58 +481,136 @@ fn parse_network( bail!("SES route refers to unknown net '{net_name}'"); } for wire in net_node.find_all("wire") { - require_direct_shape(wire, 0, &["path"])?; - let path = one_child(wire, "path")?; - 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"); + 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, + }); + } } - 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}'"); + (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, + }); } - tracks.push(SesTrack { - net_name: net_name.to_string(), - layer: (*layer).to_string(), - width_mm, - x1_mm, - y1_mm, - x2_mm, - y2_mm, - }); + _ => bail!("SES wire for net '{net_name}' must contain exactly one path or qarc"), } } for via_node in net_node.find_all("via") { @@ -554,7 +649,7 @@ fn parse_network( }); } } - Ok((tracks, vias)) + Ok((tracks, arcs, vias)) } fn parse_resolution(node: &SexpNode) -> Result { @@ -771,6 +866,7 @@ mod tests { 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); @@ -779,6 +875,18 @@ mod tests { 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(); @@ -800,15 +908,28 @@ mod tests { } #[test] - fn unsupported_route_syntax_is_refused() { + 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 0 0 1 1) (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("qarc"), "{error}"); + assert!(error.contains("exactly one path or qarc"), "{error}"); } } diff --git a/crates/konnect-core/src/tools/pcb_routing.rs b/crates/konnect-core/src/tools/pcb_routing.rs index c6958a57..47524fc4 100644 --- a/crates/konnect-core/src/tools/pcb_routing.rs +++ b/crates/konnect-core/src/tools/pcb_routing.rs @@ -131,10 +131,10 @@ pub fn tools() -> Vec { "type": "object", "properties": { "board": { "type": "string", "description": "Open source .kicad_pcb used for the DSN export" }, - "ses": { "type": "string", "description": "Freerouting .ses result" }, - "manifest": { "type": "string", "description": "Konnect reverse manifest written with the DSN" } + "ses_path": { "type": "string", "description": "Freerouting .ses result" }, + "manifest_path": { "type": "string", "description": "Konnect reverse manifest written with the DSN" } }, - "required": ["board", "ses", "manifest"] + "required": ["board", "ses_path", "manifest_path"] }), |args, ctx| async move { handle_plan_specctra_ses_import(args, ctx).await } ), @@ -145,11 +145,11 @@ pub fn tools() -> Vec { "type": "object", "properties": { "board": { "type": "string", "description": "Open source .kicad_pcb used for the DSN export" }, - "ses": { "type": "string", "description": "Freerouting .ses result" }, - "manifest": { "type": "string", "description": "Konnect reverse manifest written with the DSN" }, - "candidate_output": { "type": "string", "description": "New .kicad_pcb path. Existing files are never replaced." } + "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", "manifest", "candidate_output"] + "required": ["board", "ses_path", "manifest_path", "candidate_output_path"] }), |args, ctx| async move { handle_apply_specctra_ses(args, ctx).await } ), @@ -561,17 +561,17 @@ fn invalid_specctra_argument(name: &str, reason: &str) -> CallToolResult { async fn read_specctra_inputs( args: &serde_json::Value, ) -> anyhow::Result> { - let ses_path = get_path(args, "ses")?; - let manifest_path = get_path(args, "manifest")?; + 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", + "ses_path", "must have the .ses extension", ))); } if !manifest_path.is_file() { return Ok(Err(invalid_specctra_argument( - "manifest", + "manifest_path", "must name an existing reverse-manifest JSON file", ))); } @@ -624,8 +624,10 @@ async fn handle_plan_specctra_ses_import( "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 }))), @@ -643,6 +645,7 @@ struct ApplyEvidence { source_sha256: String, session_id: String, track_count: usize, + arc_count: usize, via_count: usize, created_count: usize, drc_violations: usize, @@ -655,7 +658,7 @@ async fn handle_apply_specctra_ses( ctx: &ToolContext, ) -> anyhow::Result { let board = get_path(args, "board")?; - let candidate = get_path(args, "candidate_output")?; + let candidate = get_path(args, "candidate_output_path")?; if !extension_is(&board, "kicad_pcb") { return Ok(invalid_specctra_argument( "board", @@ -664,7 +667,7 @@ async fn handle_apply_specctra_ses( } if !extension_is(&candidate, "kicad_pcb") { return Ok(invalid_specctra_argument( - "candidate_output", + "candidate_output_path", "must have the .kicad_pcb extension", )); } @@ -729,8 +732,9 @@ async fn handle_apply_specctra_ses( 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_vias.is_empty() { + 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" ); @@ -740,7 +744,7 @@ async fn handle_apply_specctra_ses( .into_iter() .map(|net| (net.name, net.netcode)) .collect::>(); - let mut items = Vec::with_capacity(plan.tracks.len() + plan.vias.len()); + 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 @@ -761,6 +765,18 @@ async fn handle_apply_specctra_ses( "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) @@ -794,14 +810,16 @@ async fn handle_apply_specctra_ses( ); } 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_vias.len() != plan.vias.len() { + 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/{} vias, read {} tracks/{} vias", - plan.tracks.len(), - plan.vias.len(), - read_tracks.len(), - read_vias.len() + "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())?; @@ -821,6 +839,7 @@ async fn handle_apply_specctra_ses( 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(), @@ -858,12 +877,13 @@ async fn handle_apply_specctra_ses( "success": true, "method": "strict_atomic_kicad_ipc_import", "board": board, - "candidate_output": candidate, + "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", 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-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);