← Commit history

standard 0.24.2: engine 0.21.1 (split pads are one land); kicad swap_footprint uncounted

Colby Knox ·c863f222ce ·11d ago ·parent baefb4e
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"