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.24.2: engine 0.21.1 (split pads are one land); kicad swap_footprint uncounted
8 files changed
+47−17
README.md+3−2@@ -60,12 +60,12 @@ verb by verb: [docs/coverage.md](docs/coverage.md). <!-- coverage:start (tools/generate.py writes this) --> | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 90 | 90 |+| **core** | 93 | 93 | 89 | 90 | | **native** | 47 | 47 | 44 | 42 | | **checks** | 37 | 37 | 35 | 37 | | **app** | 62 | 62 | 45 | 53 | | **platform** | 18 | 18 | 0 | 1 |-| standard (all but platform) | 239 | 239 | 214 | 222 |+| standard (all but platform) | 239 | 239 | 213 | 222 | <!-- coverage:end --> The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,7 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.24.2: engine 0.21.1: `duplicate_pad_number` counts touching or overlapping pads as one land (a split exposed pad is not a duplicate; KiCad #28). KiCad's `swap_footprint` is no longer counted as `update_footprints_from_library`, which does a different job (#27). Version 0.24.1: coverage reads Fusion 360 bridge 1.13.24 (222 of 239: add_symbol_art, list_installed_libraries and install_library partial; uninstall_library, normalize_bodies and sch_sheet_symbol unsupported). Version 0.24.0: engine 0.21.0 adds `lint_library` (#28): the shared library rules (pads, courtyard, origin, pins against pads, part numbers) with a fixed severity each, from the footprints and symbols a bridge already reads. Version 0.23.8: coverage reads KiCad bridge 1.0.88 (214 of 239: variants, and sch_zoom partial).
docs/coverage.md+3−3@@ -6,12 +6,12 @@ Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 90 | 90 |+| **core** | 93 | 93 | 89 | 90 | | **native** | 47 | 47 | 44 | 42 | | **checks** | 37 | 37 | 35 | 37 | | **app** | 62 | 62 | 45 | 53 | | **platform** | 18 | 18 | 0 | 1 |-| standard (all but platform) | 239 | 239 | 214 | 222 |+| standard (all but platform) | 239 | 239 | 213 | 222 | ## core @@ -109,7 +109,7 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `set_visibility` | Show or hide one named parameter on a SchLib component. | `altium_set_visibility` | `kicad_set_visibility` | `fusion_set_visibility` | | `sheet_params` | Write parameters onto PLACED parts on the schematic sheets (the BOM write-back): MPN, Manufacturer, Mouser/DigiKey/LCSC numbers, anything. A missing... | `altium_sheet_params` | `kicad_sheet_params` | `fusion_sheet_params` | | `silk_state` | The silkscreen as it stands: every part's body box, its designator and comment (text, location, size, rotation, shown, Altium's measured box), and the free... | `altium_silk_state` | `kicad_silk_state` | `fusion_silk_state` |-| `swap_footprint` | Replace the FIRST component on the current board with a footprint from a PcbLib at the same position, re-assigning nets by pad designator. Tracks are... | `altium_swap_footprint` | `kicad_update_footprints_from_library` (partial) | `fusion_swap_footprint` |+| `swap_footprint` | Replace the FIRST component on the current board with a footprint from a PcbLib at the same position, re-assigning nets by pad designator. Tracks are... | `altium_swap_footprint` | | `fusion_swap_footprint` | | `symbol_prims` | Read a SchLib component's drawing: primitive counts by kind, body rectangle, polyline bounding box, display mode and the footprint models. Read-only, use it... | `altium_symbol_prims` | `kicad_symbol_prims` | `fusion_symbol_prims` | | `xcopy_footprint` | Copy a footprint ACROSS libraries: from fromLib into toLib (opened and made current), optionally under a new name. | `altium_xcopy_footprint` | `kicad_xcopy_footprint` | `fusion_xcopy_footprint` (partial) |
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.21.0"+version = "0.21.1" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.21.0"+version = "0.21.1" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/src/liblint.rs+35−3@@ -57,6 +57,22 @@ fn extent(c: &Value) -> Option<(f64, f64, f64, f64)> { None } +/// A pad's copper box (the hole's, if larger), in the footprint's frame.+fn pad_box(p: &Value) -> (f64, f64, f64, f64) {+ let (x, y) = (f(&p["x"]), f(&p["y"]));+ let (w, h) = (f(&p["w"]).max(f(&p["hole"])) / 2.0, f(&p["h"]).max(f(&p["hole"])) / 2.0);+ (x - w, y - h, x + w, y + h)+}++/// How many separate lands the boxes make: boxes that touch or overlap (within 0.01 mm) join.+fn lands(boxes: &[(f64, f64, f64, f64)]) -> usize {+ let mut group: Vec<usize> = (0..boxes.len()).collect();+ fn root(g: &mut Vec<usize>, i: usize) -> usize { let mut i = i; while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }+ let touch = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64)| a.0 <= b.2 + 0.01 && b.0 <= a.2 + 0.01 && a.1 <= b.3 + 0.01 && b.1 <= a.3 + 0.01;+ for i in 0..boxes.len() { for j in i + 1..boxes.len() { if touch(&boxes[i], &boxes[j]) { let (a, b) = (root(&mut group, i), root(&mut group, j)); group[a] = b; } } }+ (0..boxes.len()).filter(|&i| root(&mut group, i) == i).count()+}+ fn finding(name: &str, kind: &str, rule: &str, detail: String) -> Value { json!({"name": name, "kind": kind, "rule": rule, "severity": severity(rule), "detail": detail}) }@@ -67,15 +83,20 @@ pub fn lint_footprint(fp: &Value) -> Vec<Value> { let mut out = Vec::new(); let pads = fp["pads"].as_array().cloned().unwrap_or_default(); if pads.is_empty() { out.push(finding(&name, "footprint", "no_pads", "the footprint has no pads".into())); }- let mut seen: BTreeMap<String, usize> = BTreeMap::new();+ let mut seen: BTreeMap<String, Vec<(f64, f64, f64, f64)>> = BTreeMap::new(); for p in &pads { let (w, h, hole) = (f(&p["w"]), f(&p["h"]), f(&p["hole"])); if (w <= 0.0 || h <= 0.0) && hole <= 0.0 { out.push(finding(&name, "footprint", "zero_size_pad", format!("pad {} is {w} x {h} mm", text(&p["number"])))); } let n = text(&p["number"]); if n.is_empty() { if !mechanical(p) { out.push(finding(&name, "footprint", "unnumbered_pad", format!("a pad at ({}, {}) has no number", f(&p["x"]), f(&p["y"])))); } }- else { *seen.entry(n).or_default() += 1; }+ else if !mechanical(p) { seen.entry(n).or_default().push(pad_box(p)); }+ }+ // pads sharing a number that touch or overlap are one land (a split exposed pad, thermal vias in it): a duplicate+ // is a number on lands apart from each other+ for (n, boxes) in &seen {+ let lands = lands(boxes);+ if lands > 1 { out.push(finding(&name, "footprint", "duplicate_pad_number", format!("pad number {n} is on {lands} separate lands ({} pads)", boxes.len()))); } }- for (n, k) in &seen { if *k > 1 { out.push(finding(&name, "footprint", "duplicate_pad_number", format!("{k} pads are numbered {n} (fine for a split thermal pad, a mistake otherwise)"))); } } let court = fp.get("courtyard"); if court.map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_courtyard", "no courtyard".into())); } // the origin should sit on the part: inside the pads and courtyard, grown by the margin@@ -193,6 +214,17 @@ mod tests { assert!(off["detail"].as_str().unwrap().starts_with("the origin is 1268.700 mm"), "{off}"); } + #[test]+ fn a_split_exposed_pad_is_one_land() {+ // KiCad's Package_SO: an exposed pad 17 as four tiles and two thermal vias, plus two pins both numbered 1+ let ep: Vec<Value> = [(-0.6, -0.6), (0.6, -0.6), (-0.6, 0.6), (0.6, 0.6)].iter().map(|&(x, y)| json!({"number": "17", "x": x, "y": y, "w": 1.2, "h": 1.2})).chain([json!({"number": "17", "x": 0.0, "y": 0.0, "w": 0.6, "h": 0.6, "hole": 0.3}), json!({"number": "17", "x": 0.3, "y": 0.0, "w": 0.0, "h": 0.0, "mechanical": true})]).collect();+ let mut pads = ep.clone();+ pads.extend([pad("1", -2.7, 1.9), pad("1", 2.7, 1.9)]);+ let r = lint_library(&json!({"library": {"footprints": [{"name": "HTSSOP", "pads": pads}]}}));+ let d: Vec<&str> = r["findings"].as_array().unwrap().iter().filter(|x| x["rule"] == "duplicate_pad_number").map(|x| x["detail"].as_str().unwrap()).collect();+ assert_eq!(d, vec!["pad number 1 is on 2 separate lands (2 pads)"], "{r}");+ }+ #[test] fn a_spare_pad_warns_and_a_mechanical_one_does_not() { let fp = json!({"name": "USB", "pads": [pad("1", 0.0, 0.0), pad("2", 0.65, 0.0), pad("S1", 3.0, 0.0), json!({"number": "", "x": -2.0, "y": 1.0, "w": 0.0, "h": 0.0, "hole": 0.65, "plated": false})]});
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.24.1",+ "version": "0.24.2", "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+2−5@@ -1,5 +1,5 @@ {- "standard": "0.24.1",+ "standard": "0.24.2", "reference": { "bridge": "altium", "version": "0.2.83"@@ -1579,10 +1579,7 @@ "verb": "altium_swap_footprint", "match": "exact" },- "kicad": {- "verb": "kicad_update_footprints_from_library",- "match": "partial"- },+ "kicad": null, "fusion360": { "verb": "fusion_swap_footprint", "match": "exact",
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.24.1",+ "standard": "0.24.2", "reference": { "bridge": "altium", "version": "0.2.83"