app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← All Pull Requests
kicad-lane: 1.0.86, review_board reads KiCad's fields and unconnected nets right, engine 0.19.1 #48
Merged opened by Colby Knox 2026-10-29
1.0.86: fixes found by testing 1.0.85 on real boards.
- kicad_review_board: KiCad's per-pin "unconnected-(U5-PB0-Pad16)" nets now count as no net, the way kicad_schematic_review treats them.
- KiCad gives every pin the schematic leaves unconnected a net of its own, so each one used to come back as a one-pad net warning.
- Checked: the ESC board went from 23 such warnings to a clean review. The rp2040 sample's real findings are unchanged.
- kicad_review_board: the vendor part number and datasheet checks now read the symbols' fields. The parts sent from KiCad's netlist used to carry none, so every part was flagged even when it had Mouser, DigiKey and LCSC numbers.
- Empty fields, and KiCad's "~" for no datasheet, don't count.
- Checked: on a copy of the rp2040 sample, giving C1 its Mouser, DigiKey and LCSC numbers and a datasheet clears both of its findings.
- kicad_footprint_from_land_pattern (engine 0.19.1, standard #25, found testing 1.0.85):
- SOT-23-5's pins 4 and 5 now line up with pins 1 and 3 (±0.95 mm). Before, they sat 0.95 mm apart, centred, so the part did not fit.
- The courtyard holds the pads and body only, so a 7 mm BGA's is symmetric at ±3.75 mm instead of stretching to the pin-1 dot.
- Engine: adom-eda-checks 0.19.1 (EDA Bridge Standard 0.22.2).
- Verb tests: the test run never overlaps another bridge's test run on the same machine. It waits up to 30 minutes, then refuses (#112).
Files: rust/Cargo.toml, rust/Cargo.lock, rust/crates/kicad-bridge/src/verbs_checks_native.rs, rust/crates/kicad-core/src/netlist.rs, rust/vendor/adom-eda-checks (0.19.1, vendored unchanged), skills/kicad-bridge-test/run_verb_tests.py.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-eda-checks"-version = "0.19.0"+version = "0.19.1" dependencies = [ "roxmltree", "serde_json",⋯ 302 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.85"+version = "1.0.86" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.85"+version = "1.0.86" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.85"+version = "1.0.86" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.85"+version = "1.0.86" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,1813 +1,1824 @@⋯ 1582 unchanged lines ⋯ 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})⋯ 49 unchanged lines ⋯ # 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⋯ 160 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+@@ -1,13 +1,14 @@⋯ 2 unchanged lines ⋯ 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`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests. Never edit the copy: fixes go to the standard (an issue titled `kicad-lane: ...`).+@@ -1,129 +1,130 @@⋯ 22 unchanged lines ⋯ | `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 |⋯ 85 unchanged lines ⋯ 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 ⋯ 5 unchanged lines ⋯ ``` Fixtures live on each bridge's own page, not here.+@@ -1,25 +1,26 @@ [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"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,276 +1,287 @@⋯ 40 unchanged lines ⋯ 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")?);⋯ 98 unchanged lines ⋯ 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;⋯ 61 unchanged lines ⋯ 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]⋯ 24 unchanged lines ⋯ 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); }⋯ 19 unchanged lines ⋯ assert_eq!(footprint_from_land_pattern(&json!({"args": {"family": "chip"}}))["errorCode"], "bad_args"); } }+@@ -1,1233 +1,1264 @@⋯ 877 unchanged lines ⋯ /// 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 };⋯ 20 unchanged lines ⋯ } } 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;⋯ 320 unchanged lines ⋯ 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}");+ } }+@@ -1,861 +1,877 @@⋯ 311 unchanged lines ⋯ } } 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})) } ⋯ 401 unchanged lines ⋯ } #[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> <fields><field name="Footprint">Capacitor_SMD:C_0402_1005Metric</field><field name="MPN">GRM155R71C104KA88D</field><field name="LCSC">C1525</field><field name="Empty"/></fields>⋯ 133 unchanged lines ⋯ assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list"); } }+
Comments
Log in to comment.
No comments yet.