← Commit history
rust/Cargo.lock+4−4
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.19.0"+version = "0.19.1" dependencies = [  "roxmltree",  "serde_json",@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.85"+version = "1.0.86" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.85"+version = "1.0.86" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.85"+version = "1.0.86" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.85"+version = "1.0.86" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_checks_native.rs+30
@@ -878,6 +878,20 @@ pub(crate) fn plan_fanout(state: &mut State, args: &Value) -> Value {  /// kicad_review_board: the board model, its rules and (when a schematic is beside it) the sheet parts from KiCad's /// netlist, reviewed by the standard's engine.+/// Every `net` in the model that is one of KiCad's per-pin "unconnected-(...)" nets becomes no net ("").+fn no_net_for_unconnected(v: &mut Value) {+    match v {+        Value::Object(o) => {+            if o.get("net").and_then(Value::as_str).is_some_and(|n| n.starts_with("unconnected-(")) {+                o.insert("net".into(), json!(""));+            }+            o.values_mut().for_each(no_net_for_unconnected);+        }+        Value::Array(a) => a.iter_mut().for_each(no_net_for_unconnected),+        _ => {}+    }+}+ fn review_board(state: &mut State, args: &Value) -> Value {     let o = match opts(args) { Ok(o) => o, Err(e) => return e };     let r = match read(state, args) { Ok(r) => r, Err(e) => return e };@@ -904,6 +918,10 @@ fn review_board(state: &mut State, args: &Value) -> Value {         }     }     ea["alwaysChecked"] = json!(always);+    // KiCad names a pin the schematic leaves unconnected "unconnected-(U5-PB0-Pad16)", one net per pin: that is no net,+    // not a one-pad net to warn about (kicad_schematic_review drops them the same way)+    let mut model = model;+    no_net_for_unconnected(&mut model);     let mut bundle = json!({"board": model, "rules": rules, "args": ea});     let sch = PathBuf::from(&r.file).with_extension("kicad_sch");     let mut schematic = Value::Null;@@ -1230,4 +1248,16 @@ mod tests {         let o = board_checks::check_orientation(&model, &json!({}));         assert!(o["checked"].as_u64().unwrap() > 10, "{o}");     }++    #[test]+    fn kicads_per_pin_unconnected_nets_are_no_net_for_the_review() {+        let mut m = json!({"pads": [{"net": "unconnected-(U5-PB0-Pad16)"}, {"net": "GND"}, {"net": "LONE"}, {"net": "GND"}], "components": [{"pads": [{"net": "unconnected-(J1-NC-Pad1)"}]}]});+        no_net_for_unconnected(&mut m);+        assert_eq!(m["pads"][0]["net"], "");+        assert_eq!(m["components"][0]["pads"][0]["net"], "");+        assert_eq!((m["pads"][1]["net"].as_str(), m["pads"][2]["net"].as_str()), (Some("GND"), Some("LONE")));+        let r = adom_eda_checks::run("review_board", &json!({"board": m, "rules": [], "args": {"checks": ["single_pin_nets"]}}));+        let nets: Vec<&str> = r["findings"].as_array().unwrap().iter().filter(|f| f["check"] == "single_pin_nets").filter_map(|f| f["net"].as_str()).collect();+        assert_eq!(nets, vec!["LONE"], "{r}");+    } }
rust/crates/kicad-core/src/netlist.rs+16−1
@@ -312,7 +312,13 @@ pub fn review_inputs(model: &Netlist) -> (serde_json::Value, serde_json::Value)         }     }     let value = |r: &str| model.component(r).map(|c| c.value.clone()).unwrap_or_default();-    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v}) }).collect();+    // the symbol's fields as the standard's params (MPN, LCSC, Mouser, Datasheet, ...): an empty field, or KiCad's "~"+    // for no datasheet, is not there+    let params = |r: &str| -> Vec<serde_json::Value> {+        model.component(r).map(|c| c.fields.iter().filter(|(_, v)| !v.trim().is_empty() && v.trim() != "~")+            .map(|(k, v)| json!({"name": k, "value": v})).collect()).unwrap_or_default()+    };+    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v, "params": params(&r)}) }).collect();     (json!({"components": comps}), json!({"nets": nets})) } @@ -719,6 +725,15 @@ mod tests {         assert_eq!(tp.libsource, "", "no libsource means empty, not a stray colon");     } +    #[test]+    fn review_parts_carry_their_fields_as_params() {+        let xml = r#"<export><components><comp ref="C1"><value>100n</value>+            <fields><field name="Datasheet">~</field><field name="LCSC">C1525</field><field name="Empty"/></fields></comp></components>+            <nets><net code="1" name="GND"><node ref="C1" pin="1" pintype="passive"/></net></nets></export>"#;+        let (parts, _) = review_inputs(&parse_kicadxml(xml).unwrap());+        assert_eq!(parts["components"][0]["params"], serde_json::json!([{"name": "LCSC", "value": "C1525"}]));+    }+     #[test]     fn keeps_fields_and_flags_for_the_bom() {         let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>Capacitor_SMD:C_0402_1005Metric</footprint>
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.19.0"+version = "0.19.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"
rust/vendor/adom-eda-checks/README.md+2−2
@@ -23,7 +23,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |-| `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs |+| `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), a shorter column spread over the longer one's span (SOT-23-5's pins 4 and 5 level with 3 and 1), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` round the pads and body grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body | | `panelize` | board, stack? | a panel plan in panel mm from its lower-left corner: `rows` x `cols` copies (each with its `copyShift`), rails (`railMm`) with three tooling holes and fiducials, and `separation` none, `vscore` (V-CUT lines edge to edge with their labels) or `tabs` (mouse-bite holes and the rout path), with JLCPCB's V-cut or mouse-bite rules checked and copper near the edge or outside the outline named. Refuses tabs when the board draws its outline on a mechanical layer. From the Altium bridge's panel.rs | | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs |@@ -115,7 +115,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.19.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.19.1"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.22.1-- Standard commit: b9da2c63e0fffbf6d5e184c81f6db0baa111ff1d-- Crate version: 0.19.0+- Standard version: 0.22.2+- Standard commit: 1bbf323d2e4e8bb452ca2ad194e2f34335d95367+- Crate version: 0.19.1 - Copied: 2026-10-29, by kicad-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/landpattern.rs+16−6
@@ -41,9 +41,14 @@ pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {                 if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }                 (n / 2, n / 2)             };-            // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)-            for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }-            for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+            // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left). A shorter+            // column spreads over the longer one's span, its middle left empty: SOT-23-5's pins 4 and 5 line up with 3 and+            // 1 (kicad-lane #25); a single pin sits in the middle (SOT-23)+            let n = left.max(right);+            let step = |count: usize| if count > 1 && count < n { (n as f64 - 1.0) * pitch / (count as f64 - 1.0) } else { pitch };+            let (ls, rs) = (step(left), step(right));+            for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * ls, pl, pw); }+            for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * rs, pl, pw); }         }         "quad" => {             let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);@@ -148,10 +153,9 @@ pub fn layout(a: &Value) -> Result<Fp, String> {         let r = 0.15;         let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };         fp.discs.push(("overlay", r3(dx), r3(dy), r));-        x0 = x0.min(dx - r);-        y1 = y1.max(dy + r);     }-    // courtyard: everything grown by the margin, on a 0.01 mm grid+    // courtyard: the pads and the body grown by the margin, on a 0.01 mm grid. The silkscreen pin-1 dot may sit outside it+    // (IPC-7351; kicad-lane #25: holding the dot pushed a 7 mm BGA's courtyard 0.5 mm out on two sides)     let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };     let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));     fp.courtyard = c;@@ -219,6 +223,9 @@ mod tests {     fn sot23_has_two_pins_left_and_one_right() {         let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();         assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+        // SOT-23-5: pins 4 and 5 line up with 3 and 1 (kicad-lane #25)+        let q = pads(&json!({"family": "sot", "leftPins": 3, "rightPins": 2, "pitch": 0.95, "span": 2.6, "padLength": 1.1, "padWidth": 0.6})).unwrap();+        assert_eq!(q.iter().map(|p| (p.name.clone(), p.y)).collect::<Vec<_>>(), vec![("1".into(), 0.95), ("2".into(), 0.0), ("3".into(), -0.95), ("4".into(), -0.95), ("5".into(), 0.95)]);     }      #[test]@@ -249,6 +256,9 @@ mod tests {         let c = fp.courtyard;         assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));         assert_eq!(fp.discs.len(), 1);+        // the courtyard holds the pads and the body, not the pin-1 dot (kicad-lane #25): a 7 mm BGA is symmetric+        let bga = layout(&json!({"family": "bga", "rows": 8, "cols": 8, "pitch": 0.8, "ballPad": 0.4, "body": {"w": 7, "l": 7}})).unwrap();+        assert_eq!(bga.courtyard, (-3.75, -3.75, 3.75, 3.75));         assert!(fp.discs[0].1 < -2.7 - 0.775);      }
skills/kicad-bridge-test/run_verb_tests.py+12−2
@@ -1583,10 +1583,17 @@ def main():         own("jlcpcb_order", r, ok(r) and (r.get("gerberZip") or {}).get("path") and isinstance(r.get("issues"), list) and (r.get("orderForm") or {}).get("layers") == 4, "no Gerber zip or order form")         lp = {"libraryPath": w["out"] + "/LandTest.pretty", "name": "SOIC-8_test", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6,               "body": {"w": 3.9, "l": 4.9, "height": 1.75}, "overwrite": True}+        # 1.0.86 (engine 0.19.1, standard #25): SOT-23-5's pins 4 and 5 line up with 1 and 3; a BGA's courtyard holds pads+        # and body only+        sot = C("footprint_from_land_pattern", {"name": "SOT-23-5", "family": "sot", "leftPins": 3, "rightPins": 2, "pitch": 0.95, "span": 2.6,+                                                "padLength": 1.1, "padWidth": 0.6, "dryRun": True})+        bga = C("footprint_from_land_pattern", {"name": "BGA", "family": "bga", "rows": 8, "cols": 8, "pitch": 0.8, "ballPad": 0.4, "body": {"w": 7, "l": 7}, "dryRun": True})         r0 = C("footprint_from_land_pattern", dict(lp, dryRun=True))         r = C("footprint_from_land_pattern", lp)         own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0] == ["1", -2.7, 1.905, 1.55, 0.6]-            and str(r0.get("engine") or "").startswith("adom-eda-checks ") and ok(r) and (r.get("body3d") or {}).get("success") is True,+            and str(r0.get("engine") or "").startswith("adom-eda-checks ") and ok(r) and (r.get("body3d") or {}).get("success") is True+            and [p[2] for p in ((sot.get("summary") or {}).get("padTable") or [])][3:] == [-0.95, 0.95]+            and (bga.get("summary") or {}).get("courtyardMm") == [-3.75, -3.75, 3.75, 3.75],             "no pad table from the engine (1.0.85: the standard's engine lays it out), or the footprint or its body was not written")         # 1.0.69: a sheet read from its file, boards built from footprints, a mouse-bite panel         r = C("read_sheet_file", {"path": w["sch"], "params": True})@@ -1642,9 +1649,12 @@ def main():         # 1.0.84: the standard's review_board (engine 0.17.2): Board Setup's rules and the sheet go in, no rule class reads as         # missing on a KiCad board, and the rp2040 sample's 12 PIN** test points are a real duplicate designator         rb = C("review_board", {"filePath": w["board"]}, 300)+        # 1.0.86: KiCad's per-pin unconnected-(...) nets are no net, so a board full of unused MCU pins has no one-pad nets+        rbc = C("review_board", {"filePath": CK}, 300)         own("review_board", rb, ok(rb) and isinstance(rb.get("findings"), list) and rb.get("engine")             and not any(x.get("check") == "missing_critical_rules" for x in rb.get("findings") or [])-            and any(x.get("check") == "duplicate_designators" and x.get("designator") == "PIN**" for x in rb.get("findings") or []),+            and any(x.get("check") == "duplicate_designators" and x.get("designator") == "PIN**" for x in rb.get("findings") or [])+            and ok(rbc) and not any(str(x.get("net") or "").startswith("unconnected-(") for x in rbc.get("findings") or []),             "no review, a rule class read as missing, or the duplicate PIN** designator was not found")         bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)         # 1.0.85 (#23 notSimulated): a connector with no model on the RC's output is left out and named; a sheet of