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..d8e7294c --- /dev/null +++ b/crates/konnect-core/src/specctra.rs @@ -0,0 +1,1091 @@ +//! 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, + x_um: i64, + y_um: i64, + rotation_degrees: f64, + side: &'static str, + 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(), + x_um: footprint.x_um, + y_um: footprint.y_um, + rotation_degrees: footprint.rotation_degrees, + side: "front", + 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")); + let manifest: serde_json::Value = serde_json::from_str(&first.manifest).unwrap(); + assert_eq!(manifest["components"][0]["reference"], "R1"); + assert_eq!(manifest["components"][0]["x_um"], 100_000); + assert_eq!(manifest["components"][0]["y_um"], -50_000); + assert_eq!(manifest["components"][0]["rotation_degrees"], 0.0); + assert_eq!(manifest["components"][0]["side"], "front"); + } + + #[test] + fn freerouting_owned_dsn_corpus_parses() { + let source = include_str!("../tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn"); + let mut tokenizer = ListTokenizer::new(BufReader::new(Cursor::new(source.as_bytes()))); + dsn::DsnFile::read_dsn(&mut tokenizer) + .expect("Freerouting v2.3.0 corpus fixture must remain parseable"); + } + + /// 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..47592de7 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_path": { + "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,192 @@ async fn handle_export_netlist( )) } +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn default_specctra_manifest_path(output_path: &Path) -> PathBuf { + let mut name = output_path.as_os_str().to_os_string(); + name.push(".konnect.json"); + PathBuf::from(name) +} + +fn existing_export_targets(paths: &[&Path]) -> Vec { + paths + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect() +} + +fn write_specctra_export_pair( + output_path: &Path, + dsn: &str, + manifest_output_path: &Path, + manifest: &str, +) -> anyhow::Result<()> { + if let Some(parent) = output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("create DSN output directory {}", parent.display()))?; + } + if let Some(parent) = manifest_output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("create manifest output directory {}", parent.display()))?; + } + + // Both writes are create-new and individually atomic. If the second write + // loses a race, remove only the exact DSN this call just created so callers + // never receive half of a routing job. + konnect_sexp::write_new_atomic(output_path, dsn) + .with_context(|| format!("create DSN {}", output_path.display()))?; + if let Err(error) = konnect_sexp::write_new_atomic(manifest_output_path, manifest) { + let cleanup = std::fs::remove_file(output_path); + if let Err(cleanup_error) = cleanup { + return Err(anyhow::anyhow!(error)).context(format!( + "create manifest {} failed and cleanup of newly-created DSN {} also failed: {cleanup_error}", + manifest_output_path.display(), + output_path.display() + )); + } + return Err(anyhow::anyhow!(error)) + .with_context(|| format!("create manifest {}", manifest_output_path.display())); + } + Ok(()) +} + +async fn handle_export_specctra_dsn( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let board_path = get_path(args, "board")?; + let output_path = get_path(args, "output")?; + let manifest_output_path = args["manifest_output_path"] + .as_str() + .map(PathBuf::from) + .unwrap_or_else(|| default_specctra_manifest_path(&output_path)); + + if !extension_is(&board_path, "kicad_pcb") { + return Ok(invalid_export_argument( + "board", + "must have the .kicad_pcb extension", + )); + } + if !extension_is(&output_path, "dsn") { + return Ok(invalid_export_argument( + "output", + "must have the .dsn extension", + )); + } + if output_path == manifest_output_path { + return Ok(invalid_export_argument( + "manifest_output_path", + "must not name the DSN output path", + )); + } + 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_path": manifest_output_path, + "source_sha256": export.source_sha256, + "component_count": export.component_count, + "pad_count": export.pad_count, + "net_count": export.net_count, + "routing_class_count": export.class_count, + "capabilities": { + "dsn_export_available": true, + "ses_import_available": false, + "freerouting_bridge_available": false, + "source_revision_bound": true, + "supported_profile": "two_layer_front_side_circle_rect_no_existing_routing_or_zones" + } + }) + .to_string(), + )) +} + async fn handle_export_position_file( args: &serde_json::Value, ctx: &ToolContext, @@ -872,6 +1090,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/freerouting_issue269_minimal_v2_3_0.README.md b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md new file mode 100644 index 00000000..5a781e32 --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.README.md @@ -0,0 +1,10 @@ +# Freerouting DSN corpus fixture + +`freerouting_issue269_minimal_v2_3_0.dsn` is copied semantically unchanged +from `freerouting/freerouting` tag `v2.3.0`, path +`fixtures/Issue269-min_fr_test/min_fr_test.dsn`; repeated whitespace and +single-clause line breaks are compacted only to keep the fixture reviewable. + +It is an engine-owned parser corpus fixture used to ensure Konnect's Specctra +dependency accepts syntax emitted and maintained by Freerouting. Freerouting is +licensed under GPL-3.0; Konnect is AGPL-3.0. diff --git a/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn new file mode 100644 index 00000000..9dd4085a --- /dev/null +++ b/crates/konnect-core/tests/fixtures/freerouting_issue269_minimal_v2_3_0.dsn @@ -0,0 +1,70 @@ +(pcb C:\freerouting_test1\min_fr_test\min_fr_test.dsn + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "KiCad's Pcbnew") + (host_version "8.0.1") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (layer In1.Cu (type power) (property (index 1))) + (layer In2.Cu (type power) (property (index 2))) + (layer B.Cu (type signal) (property (index 3))) + (boundary + (path pcb 0 179000 -101000 149000 -101000 149000 -79000 179000 -79000 + 179000 -101000) + ) + (plane GND (polygon In2.Cu 0 150000 -80000 178000 -80000 178000 -100000 150000 -100000 + 150000 -80000)) + (via "Via[0-3]_600:300_um") + (rule + (width 200) + (clearance 200) + (clearance 200 (type default_smd)) + (clearance 50 (type smd_smd)) + ) + ) + (placement + (component TestPoint:TestPoint_Pad_1.0x1.0mm + (place J4 162900.000000 -92000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J8 171000.000000 -90000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J7 159000.000000 -85000.000000 front 0.000000 (PN Conn_01x01_Pin)) + (place J1 155000.000000 -85000.000000 front 0.000000 (PN Conn_01x01_Pin)) + ) + ) + (library + (image TestPoint:TestPoint_Pad_1.0x1.0mm + (outline (path signal 120 -700 700 700 700)) + (outline (path signal 120 -700 -700 -700 700)) + (outline (path signal 120 700 700 700 -700)) + (outline (path signal 120 700 -700 -700 -700)) + (outline (path signal 50 -1000 1000 -1000 -1000)) + (outline (path signal 50 -1000 1000 1000 1000)) + (outline (path signal 50 1000 -1000 -1000 -1000)) + (outline (path signal 50 1000 -1000 1000 1000)) + (pin Rect[T]Pad_1000x1000_um 1 0 0) + ) + (padstack Rect[T]Pad_1000x1000_um + (shape (rect F.Cu -500 -500 500 500)) + (attach off) + ) + (padstack "Via[0-3]_600:300_um" + (shape (circle F.Cu 600)) + (shape (circle In1.Cu 600)) + (shape (circle In2.Cu 600)) + (shape (circle B.Cu 600)) + (attach off) + ) + ) + (network + (net "Net-(J1-Pin_1)" (pins J8-1 J1-1)) + (net GND (pins J4-1 J7-1)) + (class kicad_default "" GND "Net-(J1-Pin_1)" + (circuit (use_via Via[0-3]_600:300_um)) + (rule (width 200) (clearance 200)) + ) + ) + (wiring) +) diff --git a/crates/konnect-core/tests/fixtures/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 bcc35b6d..4626cce0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -206,7 +206,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. |