skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
← Commit history
standard 0.5.0, engine 0.2.0: wire the shared board-file writers from PR #1 (#16) into run and the CLI (list, --svg), add roxmltree and svgtypes; export_dxf draws the copper actually poured when given pours (poursAsDrawn otherwise); engine README Writers section
10 files changed
+155−21
README.md+4−1@@ -42,7 +42,8 @@ A verb one bridge gains should become every bridge's to-do: that is what this pa and its companions go in as plain JSON, findings and plans come out, and nothing calls a bridge or a tool. A bridge links the crate (vendored at a tagged standard version, since Cargo cannot fetch from the wiki) or runs the command, and answers each check under its own prefix. It now answers `copper_balance`, `return_path`, `fab_check`, `impedance` and-`current_capacity`, moved over from the Altium bridge and giving the same answers on Altium's test boards. Its+`current_capacity`, moved over from the Altium bridge and giving the same answers on Altium's test boards. It also+carries board-file writers (`export_gencad`, `export_dxf`, `import_svg`) for a bridge whose tool has no native exporter. Its conformance tests read a bridge's saved replies from `ADOM_EDA_FIXTURES`; fixtures live on each bridge's own page (Altium's: [adom/altium-bridge](https://wiki.adom.inc/adom/altium-bridge) `fixtures/`). @@ -155,6 +156,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.5.0: the engine (0.2.0) carries shared board-file writers, GenCAD, DXF per layer and SVG to regions, from+altium-lane's writers (#16, PR #1); its DXF draws the copper actually poured when given `pours`. Version 0.4.5: the engine (0.1.4) lists each layer once in `current_capacity`'s `poursOn` (kicad-lane #15); KiCad now answers four checks from the engine too. Version 0.4.4: the engine (0.1.3) counts only copper-to-copper clearance rules: a rule naming the edge, mask, silk,
engine/Cargo.lock+72−1@@ -4,9 +4,32 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.1.4"+version = "0.2.0" dependencies = [+ "roxmltree", "serde_json",+ "svgtypes",+]++[[package]]+name = "arrayvec"+version = "0.7.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"++[[package]]+name = "autocfg"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"++[[package]]+name = "euclid"+version = "0.22.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"+dependencies = [+ "num-traits", ] [[package]]@@ -15,12 +38,32 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "kurbo"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"+dependencies = [+ "arrayvec",+ "euclid",+ "smallvec",+]+ [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -39,6 +82,12 @@ dependencies = [ "proc-macro2", ] +[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"+ [[package]] name = "serde" version = "1.0.229"@@ -81,6 +130,28 @@ dependencies = [ "zmij", ] +[[package]]+name = "siphasher"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"++[[package]]+name = "smallvec"+version = "1.16.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"++[[package]]+name = "svgtypes"+version = "0.15.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"+dependencies = [+ "kurbo",+ "siphasher",+]+ [[package]] name = "syn" version = "3.0.9"
engine/Cargo.toml+5−2@@ -1,8 +1,8 @@ [package] name = "adom-eda-checks"-version = "0.1.4"+version = "0.2.0" edition = "2021"-description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool."+description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT" [lib]@@ -14,6 +14,9 @@ path = "src/main.rs" [dependencies] serde_json = "1"+# the SVG parser behind writers::svgart (pure Rust)+roxmltree = "0.20"+svgtypes = "0.15" [profile.release] opt-level = 3
engine/README.md+15−1@@ -1,4 +1,4 @@-# adom-eda-checks: the shared checks engine+# adom-eda-checks: the shared checks engine and board-file writers The checks tier of the EDA Bridge Standard, computed from the board model and its companions as plain JSON. It makes no bridge calls, runs no tool and does no I/O. A bridge links the crate (the Altium, KiCad and Fusion bridges are all@@ -20,6 +20,18 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning, designators, via fences, panels, the board report) come over from the Altium bridge a module at a time. +## Writers++For a bridge whose tool has no native exporter (`writers`, from altium-lane, #16). They stay native-tier verbs in the+standard: a bridge whose tool can write the file keeps its own exporter. No I/O: each returns the file text or the+regions, with `writtenFrom`, and the bridge writes the file or places the regions.++| Verb | Reads | What it answers |+|---|---|---|+| `export_gencad` | board | GenCAD 1.4 text, in inches: outline, pads and padstacks, shapes, components (a bottom part `MIRRORX FLIP`, as KiCad writes it), devices, signals, routes |+| `export_dxf` | board, pours? | one DXF (R12) per layer in `args.layers`: Edge.Cuts as lines and arcs or one closed polyline (`contours`), copper as tracks, arcs, pads, vias and pours. With `pours` a copper layer shows the copper actually poured; without, `poursAsDrawn` names the layers that show the pour as drawn |+| `import_svg` | svg (text) | `regions` in board mm (y up) for a layer: each shape with its holes as one contour reaching the holes through zero-width slits |+ ## Input Every check takes one JSON object:@@ -61,6 +73,8 @@ As a command: adom-eda-checks list adom-eda-checks return_path --board board.json --stack stack.json --pours pours.json --args '{"resolutionMm": 0.1}' adom-eda-checks fab_check bundle.json # or - for stdin+adom-eda-checks export_dxf --board board.json --pours pours.json --args '{"layers": ["Edge.Cuts", "F.Cu"]}'+adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20, "layer": "F.SilkS"}' ``` Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix
engine/src/lib.rs+7−4@@ -1,6 +1,7 @@ //! adom-eda-checks: the EDA Bridge Standard's checks tier, computed from the board model and its companions as plain-//! JSON. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and-//! answers the check under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`,+//! JSON, plus board-file writers (`writers`: GenCAD, DXF, SVG to regions) for a bridge whose tool has no native+//! exporter. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and+//! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //! //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).@@ -12,18 +13,20 @@ pub mod fab; pub mod geom; pub mod impedance; pub mod input;+pub mod writers; /// The checks this build answers, by standard verb name. pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"]; -/// Run one check by its standard name.+/// Run one check or writer by its standard name. pub fn run(check: &str, input: &Value) -> Value {+ if let Some(out) = writers::run(check, input) { return out; } match check { "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "fab_check" => fab::fab_check(input), "impedance" => impedance::impedance(input), "return_path" => coppermap::return_path(input),- _ => input::refuse("not_supported", &format!("{check} is not a check this engine answers (it answers {})", CHECKS.join(", "))),+ _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))), } }
engine/src/main.rs+5−2@@ -1,5 +1,6 @@ //! adom-eda-checks <check> [bundle.json | -] //! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! adom-eda-checks import_svg --svg art.svg --args '{"at": [10, 10], "widthMm": 20}' //! Prints the check's reply as JSON. `adom-eda-checks list` names the checks. use serde_json::{json, Value}; use std::io::Read;@@ -27,6 +28,8 @@ fn bundle(rest: &[String]) -> Result<Value, String> { b[key] = read(val)?; } "--args" => b["args"] = serde_json::from_str(val).map_err(|e| format!("--args: {e}"))?,+ // SVG artwork is text, not JSON+ "--svg" => b["svg"] = Value::String(std::fs::read_to_string(val).map_err(|e| format!("{val}: {e}"))?), _ => return Err(format!("unknown flag {flag}")), } }@@ -36,8 +39,8 @@ fn bundle(rest: &[String]) -> Result<Value, String> { fn main() { let argv: Vec<String> = std::env::args().skip(1).collect(); let out = match argv.first().map(String::as_str) {- None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS}),- Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS}),+ None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),+ Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}), Some(check) => match bundle(&argv[1..]) { Ok(b) => adom_eda_checks::run(check, &b), Err(e) => json!({"success": false, "errorCode": "bad_args", "error": e, "_hint": "Pass one bundle file {board, stack, pours, args}, or --board, --stack, --pours and --args."}),
engine/src/writers/dxf.rs+44−7@@ -104,7 +104,14 @@ fn pad(d: &mut Dxf, layer: &str, p: &Value) { /// One layer of the board as DXF text, with how many entities it holds. `layer` is a standard name (Edge.Cuts, F.Cu, /// In1.Cu, B.Cu, F.SilkS, F.Mask, Mechanical1 ...) or Altium's own L<id>. None: a name this writer does not know.+/// Pours are drawn as drawn (`polygons`); `layer_dxf_with` draws the copper actually poured instead. pub fn layer_dxf(state: &Value, layer: &str, contours: bool, inches: bool) -> Option<(String, usize)> {+ layer_dxf_with(state, &[], layer, contours, inches)+}++/// `layer_dxf` with pour_state's `zones`: on a copper layer that has poured fills, each island is drawn with its holes+/// (the real copper, clearances and thermal reliefs included) in place of the pour as drawn.+pub fn layer_dxf_with(state: &Value, zones: &[Value], layer: &str, contours: bool, inches: bool) -> Option<(String, usize)> { let mut d = Dxf::new(if inches { 1.0 / 25.4 } else { 1.0 }); let name = layer.replace('.', "_"); let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();@@ -156,17 +163,31 @@ pub fn layer_dxf(state: &Value, layer: &str, contours: bool, inches: bool) -> Op d.circle(&name, (f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]) / 2.0); } }- for g in arr("polygons").iter().filter(|g| g["layer"].as_str() == Some(layer)) {- let pts: Vec<(f64, f64, f64)> = g["points"].as_array().map(|ps| ps.iter().map(|p| (f(&p[0]), f(&p[1]), 0.0)).collect()).unwrap_or_default();- if pts.len() > 2 { d.polyline(&name, &pts, true, 0.0); }+ let fills: Vec<Value> = zones.iter().filter(|z| z["layer"].as_str() == Some(layer))+ .flat_map(|z| z["fills"].as_array().cloned().unwrap_or_default()).collect();+ let ring = |v: &Value| -> Vec<(f64, f64, f64)> { v.as_array().map(|ps| ps.iter().map(|p| (f(&p[0]), f(&p[1]), 0.0)).collect()).unwrap_or_default() };+ if !fills.is_empty() {+ // the copper actually poured: each island's outline and its holes (a fractured fill is one ring: same copper)+ for fl in &fills {+ for r in std::iter::once(ring(&fl["outline"])).chain(fl["holes"].as_array().cloned().unwrap_or_default().iter().map(ring)) {+ if r.len() > 2 { d.polyline(&name, &r, true, 0.0); }+ }+ }+ } else {+ for g in arr("polygons").iter().filter(|g| g["layer"].as_str() == Some(layer)) {+ let pts = ring(&g["points"]);+ if pts.len() > 2 { d.polyline(&name, &pts, true, 0.0); }+ } } } Some((d.text(if inches { 1 } else { 4 }), d.n)) } -/// writers::dxf_layers: `{board, args: {layers, contours?, units?: mm|in}}` -> one DXF text per layer.+/// writers::dxf_layers: `{board, pours?, args: {layers, contours?, units?: mm|in}}` -> one DXF text per layer. With+/// `pours` (pour_state) a copper layer shows the copper actually poured; without, the pours as drawn. pub fn dxf_layers(input: &Value) -> Value { let board = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let zones = crate::input::pours(input); let args = input.get("args").cloned().unwrap_or(json!({})); let layers: Vec<String> = args.get("layers").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); if layers.is_empty() { return crate::input::refuse("bad_args", "args.layers is required: [\"Edge.Cuts\", \"F.Cu\", ...]"); }@@ -174,14 +195,16 @@ pub fn dxf_layers(input: &Value) -> Value { let contours = args.get("contours").and_then(|v| v.as_bool()) == Some(true); let (mut files, mut unknown) = (Vec::new(), Vec::new()); for l in &layers {- match layer_dxf(&board, l, contours, inches) {+ match layer_dxf_with(&board, &zones, l, contours, inches) { Some((text, n)) => files.push(json!({"layer": l, "name": format!("{}.dxf", l.replace('.', "_")), "entities": n, "text": text})), None => unknown.push(l.clone()), } }+ let poured_layers: Vec<&String> = layers.iter().filter(|l| zones.iter().any(|z| z["layer"].as_str() == Some(l.as_str()))).collect();+ let as_drawn: Vec<&String> = layers.iter().filter(|l| !poured_layers.contains(l) && board["polygons"].as_array().map(|a| a.iter().any(|g| g["layer"].as_str() == Some(l.as_str()))).unwrap_or(false)).collect(); json!({"success": unknown.is_empty() && !files.is_empty(), "files": files, "unknownLayers": unknown, "units": if inches { "in" } else { "mm" },- "writtenFrom": "board model",- "_hint": "Written from the board model, not the tool's own exporter: outline, tracks, arcs, pads, vias and pours (texts are not drawn). Edge.Cuts with contours:true is one closed polyline, what CAM and laser tools want."})+ "writtenFrom": "board model", "poursAsDrawn": as_drawn,+ "_hint": "Written from the board model, not the tool's own exporter: outline, tracks, arcs, pads, vias and pours (texts are not drawn). Edge.Cuts with contours:true is one closed polyline, what CAM and laser tools want. poursAsDrawn lists layers whose pours are the outlines as drawn, not the copper poured: pass pours (pour_state) for the real copper."}) } #[cfg(test)]@@ -245,4 +268,18 @@ pub(crate) mod tests { assert!(r["files"][0]["text"].as_str().unwrap().contains("\nPOLYLINE\n")); assert_eq!(dxf_layers(&json!({"board": {}, "args": {}}))["errorCode"], "bad_args"); }++ #[test]+ fn poured_fills_replace_the_pour_as_drawn() {+ let st = json!({"outline": [[0, 0], [10, 0], [10, 10], [0, 10]], "tracks": [], "arcs": [], "pads": [], "vias": [],+ "polygons": [{"net": "GND", "layer": "B.Cu", "points": [[0, 0], [10, 0], [10, 10], [0, 10]]}]});+ let zones = vec![json!({"net": "GND", "layer": "B.Cu", "fills": [{"outline": [[1, 1], [9, 1], [9, 9], [1, 9]], "holes": [[[4, 4], [6, 4], [6, 6], [4, 6]]]}]})];+ // as drawn: one outline; poured: the island and its hole+ assert_eq!(layer_dxf(&st, "B.Cu", false, false).unwrap().1, 1);+ assert_eq!(layer_dxf_with(&st, &zones, "B.Cu", false, false).unwrap().1, 2);+ let r = dxf_layers(&json!({"board": st, "args": {"layers": ["B.Cu"]}}));+ assert_eq!(r["poursAsDrawn"], json!(["B.Cu"]));+ let r = dxf_layers(&json!({"board": st, "pours": {"zones": zones}, "args": {"layers": ["B.Cu"]}}));+ assert_eq!(r["poursAsDrawn"], json!([]));+ } }
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.4.5",+ "version": "0.5.0", "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.4.5",+ "standard": "0.5.0", "reference": { "bridge": "altium", "version": "0.2.78"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.4.5",+ "standard": "0.5.0", "reference": { "bridge": "altium", "version": "0.2.78"