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.
master
John Lauer
1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
39ff8a6
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
//! Schematic hierarchy, connectivity, checks and review on the KiCad 10 fixture project in
//! tests/fixtures/sch_hier: hier.kicad_sch (root) with sheets Power and MCU, and Filter nested
//! in Power (2 levels), plus checks.kicad_sch with one of each connectivity defect. The
//! fixtures were written by eeschema-format code and loaded by KiCad 10.0.2 (service-kicad ERC
//! and SVG export) before being committed. When `service-kicad` is on PATH, files these
//! modules write are run through KiCad 10's ERC too.
use std::path::{Path, PathBuf};
use std::process::Command;
use kicad_core::netlist::parse_kicadxml;
use kicad_core::sch_check::{self, CheckOpts};
use kicad_core::sch_hier::{self, Hierarchy, Paper, TitleEdit};
use kicad_core::sch_model::{self, Connectivity, Item};
use kicad_core::sch_review;
use kicad_core::sch_sheets::Workspace;
use kicad_core::schematic::{new_uuid, Schematic};
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch_hier")
}
fn copy_project(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("kc-schhier-{tag}-{}-{}", std::process::id(), new_uuid()));
std::fs::create_dir_all(&d).unwrap();
for e in std::fs::read_dir(fixtures()).unwrap().flatten() {
std::fs::copy(e.path(), d.join(e.file_name())).unwrap();
}
d
}
#[test]
fn fixtures_round_trip_byte_identical() {
for f in ["hier", "power", "filter", "mcu", "checks"] {
let p = fixtures().join(format!("{f}.kicad_sch"));
let text = std::fs::read_to_string(&p).unwrap();
let s = Schematic::parse(&text).unwrap();
assert_eq!(s.to_text(), text, "{f} round trip");
assert_eq!(s.version().unwrap(), sch_hier::SCH_VERSION);
}
}
#[test]
fn hierarchy_walks_two_levels() {
let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
let human: Vec<&str> = h.instances.iter().map(|i| i.human.as_str()).collect();
assert_eq!(human, vec!["/", "/Power/", "/Power/Filter/", "/MCU/"]);
let pages: Vec<&str> = h.instances.iter().map(|i| i.page.as_deref().unwrap_or("")).collect();
assert_eq!(pages, vec!["1", "2", "3", "4"]);
assert_eq!(h.docs.len(), 4);
let filter = &h.instances[2];
assert_eq!(filter.depth, 2);
assert_eq!(filter.path.matches('/').count(), 3);
// the instance path is what the child's symbols carry
let fs = h.sch(2).unwrap();
let m = sch_model::extract(fs, Some(&filter.path));
assert!(m.symbols.iter().any(|s| s.reference == "D1"));
assert!(h.references().contains("U2"));
assert_eq!(h.project_name(), "hier");
assert_eq!(sch_hier::project_root_for(&fixtures().join("mcu.kicad_sch")).unwrap(), fixtures().join("hier.kicad_sch"));
}
#[test]
fn pins_of_rotated_and_mirrored_symbols() {
let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
let m = sch_model::extract(&s, None);
// R2 is placed at 90 degrees: its pins run horizontally
let r2: Vec<(&str, (f64, f64))> = m.pins.iter().filter(|p| p.reference == "R2").map(|p| (p.number.as_str(), p.at)).collect();
assert_eq!(r2, vec![("1", (72.39, 46.99)), ("2", (80.01, 46.99))]);
// the regulator's pins carry their electrical types from lib_symbols
let en = m.pins.iter().find(|p| p.reference == "U1" && p.number == "3").unwrap();
assert_eq!((en.name.as_str(), en.electrical.as_str()), ("EN", "input"));
let gnd = m.pins.iter().find(|p| p.reference == "#PWR02").unwrap();
assert!(gnd.power_symbol);
assert_eq!(gnd.power_net.as_deref(), Some("GND"));
let r4 = m.symbols.iter().find(|s| s.reference == "R4").unwrap().bbox.unwrap();
assert!((r4.1 - 46.99).abs() < 1e-6 && (r4.3 - 54.61).abs() < 1e-6, "{r4:?}");
}
/// Connectivity rules on small hand-written KiCad 10 sheets.
fn sheet(body: &str) -> Schematic {
let (blank, _) = sch_hier::blank_sheet(&Paper::parse("A4", None, None, false).unwrap(), &TitleEdit::default());
let text = blank.replacen("\t(sheet_instances", &format!("{body}\t(sheet_instances"), 1);
Schematic::parse(&text).unwrap()
}
fn wire(a: (f64, f64), b: (f64, f64)) -> String {
format!("\t(wire\n\t\t(pts\n\t\t\t(xy {} {}) (xy {} {})\n\t\t)\n\t\t(uuid \"{}\")\n\t)\n", a.0, a.1, b.0, b.1, new_uuid())
}
fn label(t: &str, at: (f64, f64)) -> String {
format!("\t(label \"{t}\"\n\t\t(at {} {} 0)\n\t\t(uuid \"{}\")\n\t)\n", at.0, at.1, new_uuid())
}
fn junction(at: (f64, f64)) -> String {
format!("\t(junction\n\t\t(at {} {})\n\t\t(diameter 0)\n\t\t(uuid \"{}\")\n\t)\n", at.0, at.1, new_uuid())
}
#[test]
fn connectivity_rules() {
// a T: a wire end on another wire's interior joins it; a label anywhere on a wire names it
let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, 0.0), (10.0, 10.0)), label("A", (5.0, 0.0))].concat());
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
assert_eq!(c.net_name(&m, c.of[&Item::Wire(1)]), "A");
// a crossing without a junction does not connect; with one it does
let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, -10.0), (10.0, 10.0))].concat());
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
assert_ne!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
let t = sch_check::trace_point(&m, &c, (10.0, 0.0), 0.05);
assert_eq!(t["crossingWithoutJunction"], true);
let s = sheet(&[wire((0.0, 0.0), (20.0, 0.0)), wire((10.0, -10.0), (10.0, 10.0)), junction((10.0, 0.0))].concat());
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
// same-named local labels join two separate wires
let s = sheet(&[wire((0.0, 0.0), (10.0, 0.0)), wire((0.0, 20.0), (10.0, 20.0)), label("N", (0.0, 0.0)), label("N", (10.0, 20.0))].concat());
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
assert_eq!(c.of[&Item::Wire(0)], c.of[&Item::Wire(1)]);
assert!(sch_check::dangling_ends(&m).len() == 2, "the far ends dangle");
}
#[test]
fn a_pin_mid_wire_needs_a_junction() {
// the fixture's R6 and R7 sit mid-wire WITH junctions: both are on NETA
let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
let pin = |r: &str, n: &str| m.pins.iter().position(|p| p.reference == r && p.number == n).unwrap();
assert_eq!(c.of[&Item::Pin(pin("R6", "1"))], c.of[&Item::Pin(pin("R7", "1"))]);
// drop the junctions: the pins fall off the wire
let text = std::fs::read_to_string(fixtures().join("checks.kicad_sch")).unwrap();
let mut s2 = Schematic::parse(&text).unwrap();
let js: Vec<String> = m.junctions.iter().map(|j| j.uuid.clone()).collect();
for u in js {
s2.delete(&u).unwrap();
}
let m2 = sch_model::extract(&s2, None);
let c2 = Connectivity::build(&m2);
let wire_net = c2.of[&Item::Wire(m2.wires.iter().position(|w| w.a == (50.8, 76.2)).unwrap())];
assert_ne!(c2.of[&Item::Pin(pin("R6", "1"))], wire_net);
}
#[test]
fn check_on_the_defect_sheet() {
let s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
let r = sch_check::check(&m, &c, &CheckOpts { overlaps: true });
let count = |k: &str| r.findings.iter().filter(|f| f.check == k).count();
assert_eq!(count("dangling_wire_end"), 2);
assert_eq!(count("floating_label"), 1);
assert_eq!(count("unconnected_pin"), 1);
assert_eq!(count("shorted_nets"), 1);
assert_eq!(count("single_pin_net"), 3);
assert!(count("overlap") >= 2);
// the regulator's nets carry power names, so they are judged cross-sheet, not flagged
assert_eq!(r.cross_sheet_skipped, 3);
// the power sheet: +3V3 next to the VOUT hierarchical label is a soft "multiple names"
let s = Schematic::load(&fixtures().join("power.kicad_sch")).unwrap();
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
let r = sch_check::check(&m, &c, &CheckOpts::default());
let sh: Vec<_> = r.findings.iter().filter(|f| f.check == "shorted_nets").collect();
assert_eq!(sh.len(), 1);
assert_eq!(sh[0].severity, "warning");
assert_eq!(sh[0].nets, vec!["+3V3", "VOUT"]);
}
#[test]
fn two_rails_on_one_wire_are_an_error() {
let s = sheet(&[
wire((0.0, 0.0), (20.0, 0.0)),
"\t(global_label \"+5V\"\n\t\t(shape input)\n\t\t(at 0 0 180)\n\t\t(uuid \"g1\")\n\t)\n".to_string(),
"\t(global_label \"+3V3\"\n\t\t(shape input)\n\t\t(at 20 0 0)\n\t\t(uuid \"g2\")\n\t)\n".to_string(),
]
.concat());
let m = sch_model::extract(&s, None);
let c = Connectivity::build(&m);
let r = sch_check::check(&m, &c, &CheckOpts::default());
let f = r.findings.iter().find(|f| f.check == "shorted_nets").unwrap();
assert_eq!(f.severity, "error");
}
#[test]
fn fix_plan_and_apply_round_trip() {
let mut s = Schematic::load(&fixtures().join("checks.kicad_sch")).unwrap();
let m = sch_model::extract(&s, None);
let (moves, left) = sch_check::plan_fixes(&m, 0.5);
assert_eq!(moves.len(), 1);
assert_eq!(left.len(), 1);
assert!(!moves[0].diagonal);
assert_eq!(sch_check::apply_moves(&mut s, &moves), 1);
let s2 = Schematic::parse(&s.to_text()).unwrap();
let m2 = sch_model::extract(&s2, None);
assert_eq!(sch_check::dangling_ends(&m2).len(), 1);
// a tolerance too tight to reach finds nothing
assert!(sch_check::plan_fixes(&m, 0.2).0.is_empty());
}
#[test]
fn review_from_the_hierarchy() {
let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
let m = sch_review::from_hierarchy(&h);
assert_eq!(m.sheets, 4);
// nets join across the hierarchy: the root's VOUT-to-VDD wire, +3V3 and the MCU's VDD are one
let u2_vdd = m.parts.iter().find(|p| p.reference == "U2").unwrap().pins.iter().find(|p| p.number == "8").unwrap();
assert_eq!(u2_vdd.net, "+3V3");
let j2 = m.parts.iter().find(|p| p.reference == "J2").unwrap();
assert_eq!(j2.pins[0].net, "/MCU/SDA");
let d1 = m.parts.iter().find(|p| p.reference == "D1").unwrap();
assert!(d1.pins.iter().any(|p| p.net == "+5V"), "VIN in Filter is +5V through the sheet pin");
let f = sch_review::review(&m, None);
let checks: Vec<&str> = f.iter().map(|x| x.check).collect();
for want in ["decoupling", "i2c_pullups", "reset_enable", "led_resistor", "regulator_caps", "bulk_capacitance"] {
assert!(checks.contains(&want), "missing {want}: {checks:?}");
}
assert!(!checks.contains(&"annotation"));
let s = sch_review::summary(&m, &f);
assert_eq!(s["errors"], 1);
}
/// The same checks on kicad-cli's netlist shape (canned kicadxml, as kicad-cli 10 writes it).
#[test]
fn review_from_a_kicad_cli_netlist() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
<components>
<comp ref="U1"><value>AP2112K-3.3</value><footprint>Package_TO_SOT_SMD:SOT-23-5</footprint><libsource lib="Regulator_Linear" part="AP2112K-3.3" description="LDO"/></comp>
<comp ref="C1"><value>10u</value><libsource lib="Device" part="C"/></comp>
<comp ref="C2"><value>10u</value><libsource lib="Device" part="C"/></comp>
<comp ref="U2"><value>BME280</value><libsource lib="Sensor" part="BME280"/></comp>
<comp ref="R1"><value>4k7</value><libsource lib="Device" part="R"/></comp>
<comp ref="R2"><value>4k7</value><libsource lib="Device" part="R"/></comp>
<comp ref="C3"><value>100n</value><libsource lib="Device" part="C"/></comp>
<comp ref="D1"><value>RED</value><libsource lib="Device" part="LED"/></comp>
<comp ref="R3"><value>1k</value><libsource lib="Device" part="R"/></comp>
<comp ref="R3"><value>1k</value><libsource lib="Device" part="R"/></comp>
</components>
<nets>
<net code="1" name="+5V"><node ref="U1" pin="1" pinfunction="VIN" pintype="power_in"/><node ref="U1" pin="3" pinfunction="EN" pintype="input"/><node ref="C1" pin="1" pintype="passive"/></net>
<net code="2" name="GND"><node ref="U1" pin="2" pinfunction="GND" pintype="power_in"/><node ref="C1" pin="2" pintype="passive"/><node ref="C2" pin="2" pintype="passive"/><node ref="U2" pin="1" pinfunction="GND" pintype="power_in"/><node ref="C3" pin="2" pintype="passive"/><node ref="D1" pin="1" pinfunction="K" pintype="passive"/></net>
<net code="3" name="+3V3"><node ref="U1" pin="5" pinfunction="VOUT" pintype="power_out"/><node ref="C2" pin="1" pintype="passive"/><node ref="U2" pin="8" pinfunction="VDD" pintype="power_in"/><node ref="U2" pin="6" pinfunction="VDDIO" pintype="power_in"/><node ref="R1" pin="2" pintype="passive"/><node ref="R2" pin="2" pintype="passive"/><node ref="C3" pin="1" pintype="passive"/></net>
<net code="4" name="/SDA"><node ref="U2" pin="3" pinfunction="SDI" pintype="bidirectional"/><node ref="R1" pin="1" pintype="passive"/></net>
<net code="5" name="/SCL"><node ref="U2" pin="4" pinfunction="SCK" pintype="input"/><node ref="R2" pin="1" pintype="passive"/></net>
<net code="6" name="Net-(D1-A)"><node ref="D1" pin="2" pinfunction="A" pintype="passive"/><node ref="R3" pin="1" pintype="passive"/></net>
<net code="7" name="+5V_LED"><node ref="R3" pin="2" pintype="passive"/></net>
</nets>
</export>"#;
let n = parse_kicadxml(xml).unwrap();
let m = sch_review::from_netlist(&n);
assert_eq!(m.source, "netlist");
let f = sch_review::review(&m, None);
let checks: Vec<&str> = f.iter().map(|x| x.check).collect();
// a good design: decoupled, pulled up, EN tied to VIN, LED limited, caps at the LDO
for clean in ["decoupling", "i2c_pullups", "reset_enable", "led_resistor", "regulator_caps", "bulk_capacitance"] {
assert!(!checks.contains(&clean), "{clean} flagged: {:#?}", f);
}
// the netlist folds the two R3s into one part: duplicates come from the files
assert!(f.is_empty(), "{f:#?}");
let mut m = m;
m.occurrences.push(("R3".into(), 1, "/ other".into()));
let f = sch_review::review(&m, Some(&["annotation".to_string()]));
assert_eq!(f.len(), 1);
assert!(f[0].message.contains("Reference R3 is used by 2"));
// the files' occurrences of the fixture: every symbol once
let h = Hierarchy::load(&fixtures().join("hier.kicad_sch")).unwrap();
assert_eq!(sch_review::occurrences(&h).len(), 10);
}
#[test]
fn workspace_ops_write_files_that_parse_and_load() {
let d = copy_project("ws");
let root = d.join("hier.kicad_sch");
// add a sheet to a SUB-sheet: the instance path runs through the root
let mut ws = Workspace::open(&d.join("mcu.kicad_sch"), None).unwrap();
assert_eq!(ws.paths().len(), 1);
let v = ws.add_sheet("Debug", "debug.kicad_sch", Some((50.8, 120.65)), None, false).unwrap();
assert_eq!(v["pages"][0]["page"], "5");
assert!(v["pages"][0]["path"].as_str().unwrap().starts_with(&ws.paths()[0]));
let w = ws.commit().unwrap();
assert_eq!(w.created.len(), 1);
assert_eq!(w.changed.len(), 1);
let h = Hierarchy::load(&root).unwrap();
assert!(h.instances.iter().any(|i| i.human == "/MCU/Debug/" && i.doc.is_some()));
// commit refuses to overwrite a file it was asked to create
let mut ws = Workspace::open(&root, None).unwrap();
ws.add_sheet("Again", "debug2.kicad_sch", None, None, false).unwrap();
std::fs::write(d.join("debug2.kicad_sch"), "taken").unwrap();
assert!(ws.commit().is_err());
assert_eq!(std::fs::read_to_string(d.join("debug2.kicad_sch")).unwrap(), "taken");
// reuse an existing sheet file: its parts get their own references for the new use
std::fs::remove_file(d.join("debug2.kicad_sch")).unwrap();
let mut ws = Workspace::open(&root, None).unwrap();
let v = ws.add_sheet("Filter2", "filter.kicad_sch", None, None, true).unwrap();
assert_eq!(v["pinsAdded"][0]["name"], "VIN");
let renamed: Vec<String> = v["renamed"].as_array().unwrap().iter().map(|r| r["to"].as_str().unwrap().to_string()).collect();
assert!(renamed.contains(&"D2".to_string()), "{renamed:?}");
ws.commit().unwrap();
let h = Hierarchy::load(&root).unwrap();
let rm = sch_review::from_hierarchy(&h);
let anno = sch_review::review(&rm, Some(&["annotation".to_string()]));
assert!(anno.is_empty(), "{anno:#?}");
for (p, _) in &h.docs {
Schematic::load(p).unwrap();
}
let _ = std::fs::remove_dir_all(&d);
}
fn service_kicad() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).map(|d| d.join("service-kicad")).find(|p| p.is_file())
}
/// KiCad 10's own verdict on what these modules write: a blank sheet with a custom page and
/// title block, a parent with a new sheet and imported pins, and a duplicated sheet. ERC must
/// produce a report (KiCad refusing a file fails here).
#[test]
fn service_kicad_loads_what_we_write() {
let Some(svc) = service_kicad() else {
eprintln!("service-kicad not on PATH; skipping the live KiCad 10 test");
return;
};
let d = copy_project("live");
let (blank, _) = sch_hier::blank_sheet(&Paper::parse("custom", Some(250.0), Some(180.0), false).unwrap(), &TitleEdit { title: Some("Live".into()), comments: vec![(1, "c1".into())], ..Default::default() });
std::fs::write(d.join("blank.kicad_sch"), blank).unwrap();
let mut ws = Workspace::open(&d.join("hier.kicad_sch"), None).unwrap();
ws.duplicate_sheet("MCU", Some("MCU_B"), None, None).unwrap();
ws.add_sheet("FilterReuse", "filter.kicad_sch", None, None, true).unwrap();
ws.add_pin("Power", "EXTRA", "passive", None, None).unwrap();
ws.commit().unwrap();
for f in ["blank.kicad_sch", "hier.kicad_sch", "mcu_copy.kicad_sch", "filter.kicad_sch"] {
let out = d.join(format!("{f}.erc.json"));
let r = Command::new(&svc).args(["sch", "erc", "--format", "json", "--out"]).arg(&out).arg(d.join(f)).output().expect("service-kicad runs");
let report = std::fs::read_to_string(&out).unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&report).unwrap_or_else(|_| panic!("{f}: KiCad 10 wrote no ERC report: {}", String::from_utf8_lossy(&r.stderr)));
assert!(v["kicad_version"].as_str().unwrap_or("").starts_with("10."), "{f}: {v}");
assert!(v["sheets"].is_array(), "{f}");
}
let _ = std::fs::remove_dir_all(&d);
}