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
Colby Knox
Merge PR #357: kicad-lane: 1.0.90, swap_footprint, plate_vias, normalize_bodies, symbol_mode, explained refusals (#29)
e955f3c
11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
//! Board and footprint edits on the repository's KiCad 10 boards: the blank template
//! (`templates/blank-board.kicad_pcb`) and the four-layer ESC G431 placement fixture
//! (`demo/placement/esc-g431-unrouted.kicad_pcb`, 149 footprints, a stack-up, inner planes).
//! Every edit is read back through the bridge's own board parser (`pcb::parse_pcb_text`)
//! and, when `service-kicad` is on PATH, through KiCad 10 itself (a DRC run must load it).
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
use kicad_core::board_edit::{self as be, Change};
use kicad_core::pcb;
use kicad_core::schematic::new_uuid;
use kicad_core::sxedit;
fn repo_file(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}
fn blank() -> String {
std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).unwrap()
}
fn esc() -> String {
std::fs::read_to_string(repo_file("demo/placement/esc-g431-unrouted.kicad_pcb")).unwrap()
}
fn parse(t: &str) -> pcb::Board {
pcb::parse_pcb_text(t, "t.kicad_pcb").unwrap()
}
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("kicad-core-board-edit-{tag}-{}-{}", std::process::id(), new_uuid()));
std::fs::create_dir_all(&d).unwrap();
d
}
const R0603: &str = "(footprint \"R_0603_1608Metric\"\n\t(version 20241229)\n\t(generator \"pcbnew\")\n\t(generator_version \"9.0\")\n\t(layer \"F.Cu\")\n\t(descr \"Resistor SMD 0603\")\n\t(property \"Reference\" \"REF**\" (at 0 -1.43 0) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000001\") (effects (font (size 1 1) (thickness 0.15))))\n\t(property \"Value\" \"R_0603_1608Metric\" (at 0 1.43 0) (layer \"F.Fab\") (uuid \"8a2c3b4e-0000-4000-8000-000000000002\") (effects (font (size 1 1) (thickness 0.15))))\n\t(attr smd)\n\t(fp_line (start -0.237258 -0.5225) (end 0.237258 -0.5225) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000003\"))\n\t(fp_rect (start -1.48 -0.73) (end 1.48 0.73) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"8a2c3b4e-0000-4000-8000-000000000004\"))\n\t(pad \"1\" smd roundrect (at -0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000005\"))\n\t(pad \"2\" smd roundrect (at 0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000006\"))\n\t(embedded_fonts no)\n\t(model \"${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))\n)\n";
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 loads the board (a DRC report comes back); None when service-kicad is absent.
fn kicad_loads(tag: &str, text: &str) -> Option<Value> {
let svc = service_kicad()?;
let dir = temp_dir(tag);
let path = dir.join(format!("{tag}.kicad_pcb"));
std::fs::write(&path, text).unwrap();
let report = dir.join("drc.json");
let out = Command::new(&svc).args(["pcb", "drc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().ok()?;
if !report.is_file() {
let msg = format!("{}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
// the service being unreachable is not a failure of the edit
if msg.to_lowercase().contains("connect") || msg.to_lowercase().contains("unavailable") || msg.to_lowercase().contains("timed out") {
eprintln!("service-kicad unreachable; skipping KiCad load check for {tag}: {msg}");
return None;
}
panic!("KiCad did not load the edited board {tag}: {msg}");
}
Some(serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap())
}
#[test]
fn outline_rectangle_rounded_and_polygon_on_the_blank_board() {
let t = blank();
let c = be::outline(&t, &json!({"width": 50, "height": 30, "x": 100, "y": 80})).unwrap();
let b = parse(&c.candidate);
let d = b.dimensions.unwrap();
assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (100.0, 80.0, 150.0, 110.0));
assert_eq!(c.create.len(), 1);
// a second outline is refused, a replacement removes the first
assert_eq!(be::outline(&c.candidate, &json!({"width": 10, "height": 10})).unwrap_err().code, "outline_exists");
let r = be::outline(&c.candidate, &json!({"shape": "rounded", "width": 40, "height": 20, "radius": 3, "x": 10, "y": 10, "replace": true})).unwrap();
assert_eq!(r.remove.len(), 1);
assert_eq!(r.create.len(), 8, "four lines and four true arcs");
let d = parse(&r.candidate).dimensions.unwrap();
assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (10.0, 10.0, 50.0, 30.0));
assert!(r.candidate.contains("(gr_arc (start 10 13) (mid"), "{}", &r.candidate[r.candidate.len() - 900..]);
assert!(!r.candidate.contains("(gr_rect"));
let p = be::outline(&t, &json!({"points": [[0, 0], [30, 0], [30, 20], [15, 28], [0, 20]]})).unwrap();
let d = parse(&p.candidate).dimensions.unwrap();
assert_eq!((d.max_x, d.max_y), (30.0, 28.0));
assert!(be::outline(&t, &json!({"shape": "rounded", "width": 10, "height": 10, "radius": 6})).is_err());
if let Some(rep) = kicad_loads("outline", &r.candidate) {
// a closed outline: no "board has malformed outline" finding
let bad = rep["violations"].as_array().unwrap().iter().any(|v| v["type"] == "invalid_outline");
assert!(!bad, "KiCad finds the rounded outline malformed: {rep}");
}
}
#[test]
fn layer_count_grows_with_the_stackup_and_refuses_to_drop_used_planes() {
let t = blank();
let c = be::set_layer_count(&t, &json!({"count": 4})).unwrap();
assert_eq!(pcb::copper_layers(&parse(&c.candidate)), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);
assert!(c.candidate.contains("(4 \"In1.Cu\" signal)") && c.candidate.contains("(6 \"In2.Cu\" signal)"));
let back = be::set_layer_count(&c.candidate, &json!({"count": 2})).unwrap();
assert_eq!(pcb::copper_layers(&parse(&back.candidate)), vec!["F.Cu", "B.Cu"]);
assert!(be::set_layer_count(&t, &json!({"count": 3})).is_err());
let e = esc();
let six = be::set_layer_count(&e, &json!({"count": 6})).unwrap();
assert_eq!(pcb::copper_layers(&parse(&six.candidate)).len(), 6);
let root = sxedit::parse(&six.candidate).unwrap();
let stack = root.kid("setup").unwrap().kid("stackup").unwrap();
let names: Vec<&str> = stack.kids_tagged("layer").filter_map(|l| l.atom(1)).collect();
let cu: Vec<&&str> = names.iter().filter(|n| n.ends_with(".Cu")).collect();
assert_eq!(cu, vec![&"F.Cu", &"In1.Cu", &"In2.Cu", &"In3.Cu", &"In4.Cu", &"B.Cu"]);
assert_eq!(names.iter().filter(|n| n.starts_with("dielectric")).count(), 5);
assert_eq!(six.report["stackup"]["rewritten"], true);
// the ESC's inner layers carry planes: dropping them is refused with the counts
let err = be::set_layer_count(&e, &json!({"count": 2})).unwrap_err();
assert_eq!(err.code, "layers_in_use");
assert!(err.detail["itemsPerLayer"]["In1.Cu"].as_u64().unwrap() > 0);
if let Some(rep) = kicad_loads("six-layers", &six.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
#[test]
fn delete_graphics_by_filter_and_by_uuid() {
let e = esc();
let edge = be::delete_graphics(&e, &json!({"layer": "Edge.Cuts"})).unwrap();
assert!(edge.report["count"].as_u64().unwrap() >= 1);
assert!(parse(&edge.candidate).dimensions.is_none(), "every Edge.Cuts graphic is gone");
assert_eq!(edge.remove.len() as u64, edge.report["count"].as_u64().unwrap());
let one = edge.remove[0].clone();
let by_id = be::delete_graphics(&e, &json!({"uuids": [one]})).unwrap();
assert_eq!(by_id.report["count"], 1);
assert_eq!(be::delete_graphics(&e, &json!({"uuids": ["nope"]})).unwrap_err().code, "unknown_ids");
assert_eq!(be::delete_graphics(&e, &json!({})).unwrap_err().code, "missing_arg");
assert_eq!(be::delete_graphics(&e, &json!({"type": "blob"})).unwrap_err().code, "invalid_args");
}
#[test]
fn mounting_holes_npth_generated_and_plated_on_a_net() {
let e = esc();
let h = be::add_mounting_hole(&e, None, &json!({"x": 106, "y": 60, "drill": 3.2})).unwrap();
let b = parse(&h.candidate);
let fp = b.footprints.iter().find(|f| f.reference == h.report["ref"].as_str().unwrap()).unwrap();
assert_eq!(fp.pads[0].kind, "np_thru_hole");
assert!(!h.copper);
let p = be::add_mounting_hole(&e, None, &json!({"x": 166, "y": 60, "drill": 3.2, "plated": true, "padDiameter": 6, "net": "GND", "reference": "H9"})).unwrap();
let b = parse(&p.candidate);
let fp = b.footprints.iter().find(|f| f.reference == "H9").unwrap();
assert_eq!(fp.pads[0].kind, "thru_hole");
assert_eq!(fp.pads[0].net_name.as_deref(), Some("GND"));
assert_eq!(fp.pads[0].width, Some(6.0));
assert!(p.copper);
assert_eq!(be::add_mounting_hole(&e, None, &json!({"x": 1, "y": 1, "drill": 3, "net": "NOPE"})).unwrap_err().code, "unknown_net");
// a stock library footprint, when the caller found one
let lib = "(footprint \"MountingHole_3.2mm_M3\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -4.2 0) (layer \"F.SilkS\") (effects (font (size 1 1)))) (property \"Value\" \"MountingHole_3.2mm_M3\" (at 0 4.2 0) (layer \"F.Fab\") (effects (font (size 1 1)))) (attr exclude_from_pos_files exclude_from_bom) (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\")))";
let s = be::add_mounting_hole(&e, Some((lib, "MountingHole:MountingHole_3.2mm_M3")), &json!({"x": 106, "y": 130, "drill": 3.2})).unwrap();
assert_eq!(s.report["footprint"], "MountingHole:MountingHole_3.2mm_M3");
if let Some(rep) = kicad_loads("holes", &p.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
#[test]
fn svg_logo_lands_as_filled_polygons_scaled_to_width() {
let t = blank();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><path d="M0 0 H200 V100 H0 Z M50 25 h100 v50 h-100 z"/></svg>"#;
let c = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "F.SilkS"})).unwrap();
assert_eq!(c.report["polygons"], 1);
assert_eq!(c.report["sizeMm"], json!([20.0, 10.0]));
assert!(c.candidate.contains("(fill yes) (layer \"F.SilkS\")"));
let back = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "B.SilkS"})).unwrap();
assert_eq!(back.report["mirrored"], true);
assert_eq!(be::import_svg(&t, svg, &json!({"x": 0, "y": 0, "layer": "Edge.Cuts"})).unwrap_err().code, "invalid_layer");
assert_eq!(be::import_svg(&t, "<svg/>", &json!({"x": 0, "y": 0})).unwrap_err().code, "svg_empty");
if let Some(rep) = kicad_loads("svg", &c.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
#[test]
fn add_footprint_from_a_library_as_an_array_and_as_a_duplicate() {
let e = esc();
let c = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 70, "reference": "R", "value": "10k", "count": 3, "pitch": [3, 0]})).unwrap();
let b = parse(&c.candidate);
let added: Vec<&pcb::Footprint> = b.footprints.iter().filter(|f| f.footprint == "Resistor_SMD:R_0603_1608Metric").collect();
assert_eq!(added.len(), 3);
let xs: Vec<f64> = added.iter().map(|f| f.position.as_ref().unwrap().x).collect();
assert_eq!(xs, vec![180.0, 183.0, 186.0]);
assert!(added.iter().all(|f| f.value == "10k" && f.reference.starts_with('R')));
let refs: Vec<&str> = added.iter().map(|f| f.reference.as_str()).collect();
let before = parse(&e);
assert!(refs.iter().all(|r| !before.footprints.iter().any(|f| f.reference == *r)), "fresh references {refs:?}");
assert_eq!(c.create.len(), 3);
assert_eq!(c.expect.len(), 3);
// one on the back with nets
let one = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 80, "rotation": 90, "side": "B.Cu", "reference": "R900", "nets": {"1": "GND", "2": "+3V3"}})).unwrap();
let b = parse(&one.candidate);
let r = b.footprints.iter().find(|f| f.reference == "R900").unwrap();
assert_eq!(r.layer.as_deref(), Some("B.Cu"));
assert_eq!(r.position.as_ref().unwrap().rotation, Some(90.0));
assert_eq!(r.pads.iter().map(|p| p.net_name.clone().unwrap_or_default()).collect::<Vec<_>>(), vec!["GND", "+3V3"]);
assert!(r.pads.iter().all(|p| p.layers.as_ref().unwrap()[0] == "B.Cu"));
assert_eq!(be::add_footprint(&e, Some((R0603, "x:y")), &json!({"x": 1, "y": 1, "reference": "C7"})).unwrap_err().code, "duplicate_reference");
// duplicate of C7: new reference, no nets, no schematic link
let d = be::add_footprint(&e, None, &json!({"duplicateOf": "C7", "x": 190, "y": 70})).unwrap();
let b = parse(&d.candidate);
let newref = d.report["added"][0]["ref"].as_str().unwrap();
assert!(newref.starts_with('C') && newref != "C7");
let dup = b.footprints.iter().find(|f| f.reference == newref).unwrap();
assert_eq!(dup.footprint, "Capacitor_SMD:C_0805_2012Metric");
assert!(dup.pads.iter().all(|p| p.net_name.is_none()));
assert!(dup.path.is_none());
let orig = b.footprints.iter().find(|f| f.reference == "C7").unwrap();
assert_ne!(dup.uuid, orig.uuid);
if let Some(rep) = kicad_loads("add", &one.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
/// The board positions of a footprint's pads, the way KiCad places them.
fn pad_xy(b: &pcb::Board, r: &str) -> Vec<(String, f64, f64, String)> {
let mut v: Vec<(String, f64, f64, String)> = pcb::pads(b).into_iter().filter(|p| p.reference == r).map(|p| (p.pad, (p.x * 1e4).round() / 1e4, (p.y * 1e4).round() / 1e4, p.layers.first().cloned().unwrap_or_default())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
#[test]
fn flip_mirrors_every_pad_about_the_anchor_and_twice_is_the_identity() {
let e = esc();
let before = parse(&e);
let c7 = before.footprints.iter().find(|f| f.reference == "C7").unwrap();
let (ax, _ay) = (c7.position.as_ref().unwrap().x, c7.position.as_ref().unwrap().y);
let pads_before = pad_xy(&before, "C7");
let c = be::flip_footprints(&e, &json!({"refs": ["C7"]})).unwrap();
let after = parse(&c.candidate);
let f = after.footprints.iter().find(|f| f.reference == "C7").unwrap();
assert_eq!(f.layer.as_deref(), Some("B.Cu"));
let pads_after = pad_xy(&after, "C7");
for (b, a) in pads_before.iter().zip(&pads_after) {
// a left/right flip about the anchor: x mirrors, y stays
assert!((a.1 - (2.0 * ax - b.1)).abs() < 1e-3 && (a.2 - b.2).abs() < 1e-3, "pad {} {:?} -> {:?}", b.0, b, a);
}
assert_eq!(c.remove.len(), 1);
assert_eq!(c.expect[0].side.as_deref(), Some("B.Cu"));
assert!(!c.create[0].contains(c7.uuid.as_deref().unwrap()), "the live copy carries fresh UUIDs");
assert!(c.candidate.contains(c7.uuid.as_deref().unwrap()), "the file keeps the UUID");
let twice = be::flip_footprints(&c.candidate, &json!({"ref": "C7"})).unwrap();
assert_eq!(pad_xy(&parse(&twice.candidate), "C7"), pads_before);
let tb = be::flip_footprints(&e, &json!({"ref": "C7", "direction": "top_bottom"})).unwrap();
let ay = c7.position.as_ref().unwrap().y;
for (b, a) in pads_before.iter().zip(&pad_xy(&parse(&tb.candidate), "C7")) {
assert!((a.1 - b.1).abs() < 1e-3 && (a.2 - (2.0 * ay - b.2)).abs() < 1e-3, "top/bottom pad {} {:?} -> {:?}", b.0, b, a);
}
assert_eq!(be::flip_footprints(&e, &json!({"ref": "NOPE"})).unwrap_err().code, "unknown_footprint");
if let Some(rep) = kicad_loads("flip", &c.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
#[test]
fn delete_and_edit_footprints() {
let e = esc();
let d = be::delete_footprints(&e, &json!({"refs": ["C7", "U1"]})).unwrap();
let b = parse(&d.candidate);
assert!(!b.footprints.iter().any(|f| f.reference == "C7" || f.reference == "U1"));
assert_eq!(b.footprints.len(), 147);
assert_eq!(d.remove.len(), 2);
assert!(d.expect.iter().all(|x| !x.present));
let locked = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap().candidate;
let locked = be::edit_footprints(&locked, &json!({"ref": "C7", "locked": true})).unwrap().candidate;
assert_eq!(be::delete_footprints(&locked, &json!({"ref": "C7"})).unwrap_err().code, "footprint_locked");
assert!(be::delete_footprints(&locked, &json!({"ref": "C7", "allowLocked": true})).is_ok());
let ed = be::edit_footprints(&e, &json!({"ref": "C7", "value": "2u2", "fields": {"LCSC": "C99", "Note": "hand fit"}, "dnp": true, "excludeFromBom": true})).unwrap();
let b = parse(&ed.candidate);
let c7 = b.footprints.iter().find(|f| f.reference == "C7").unwrap();
assert_eq!(c7.value, "2u2");
assert_eq!(c7.properties.get("LCSC").map(String::as_str), Some("C99"));
assert_eq!(c7.properties.get("Note").map(String::as_str), Some("hand fit"));
assert!(c7.attr.contains("smd"));
let row = &ed.report["edited"][0];
assert_eq!(row["after"]["attributes"]["dnp"], true);
assert_eq!(row["before"]["attributes"]["dnp"], false);
let ren = be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C700"})).unwrap();
assert!(parse(&ren.candidate).footprints.iter().any(|f| f.reference == "C700"));
assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C8"})).unwrap_err().code, "duplicate_reference");
assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7"})).unwrap_err().code, "missing_arg");
assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "fields": {"Value": null}})).unwrap_err().code, "invalid_args");
}
#[test]
fn align_and_distribute_by_courtyard() {
let e = esc();
let b = parse(&e);
let (moves, rep) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "left"})).unwrap();
assert_eq!(moves.len(), 3);
let places = kicad_core::placement::placements(&b);
let min_left = ["C7", "C8", "C9"].iter().map(|r| places.iter().find(|p| p.reference == *r).unwrap().courtyard.min_x).fold(f64::MAX, f64::min);
for m in &moves {
let p = places.iter().find(|p| p.reference == m["ref"].as_str().unwrap()).unwrap();
let shift = m["x"].as_f64().unwrap() - p.x;
assert!((p.courtyard.min_x + shift - min_left).abs() < 1e-6);
assert_eq!(m["y"].as_f64().unwrap(), p.y);
}
assert_eq!(rep["mode"], "left");
let (d, _) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "distributeY", "gap": 1})).unwrap();
assert_eq!(d.len(), 3);
assert!(be::align_moves(&b, &json!({"refs": ["C7"], "mode": "left"})).is_err());
assert!(be::align_moves(&b, &json!({"refs": ["C7", "C8"], "mode": "diagonal"})).is_err());
// the moves are exactly what kicad_move_footprint plans (file dry run)
let plan = kicad_core::placement::plan_moves(&b, &json!({"refs": moves, "allowOverlap": true, "allowOutside": true})).unwrap();
assert_eq!(plan.moves.len(), 3);
}
/// C7's own block turned back into a library footprint: the update finds nothing to do.
fn c7_as_library(e: &str) -> String {
let root = sxedit::parse(e).unwrap();
let c7 = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == "C7").unwrap();
let block = kicad_core::fp_text::move_block(c7.src(e), 0.0, 0.0, 0.0).unwrap();
let mut b = kicad_core::fp_text::strip_nets(&block).unwrap();
b = kicad_core::fp_text::strip_symbol_link(&b).unwrap();
b.replacen("Capacitor_SMD:C_0805_2012Metric", "C_0805_2012Metric", 1)
}
#[test]
fn update_from_library_plans_then_applies_keeping_nets_fields_and_pose() {
let e = esc();
let lib = c7_as_library(&e);
let mut same = |_: &str| -> Result<String, String> { Ok(lib.clone()) };
let plan = be::update_from_library(&e, &json!({"ref": "C7"}), &mut same).unwrap();
assert_eq!(plan.report["footprints"][0]["status"], "up_to_date", "{}", plan.report);
assert!(plan.remove.is_empty());
// the library grew its pads: the plan says so, the apply keeps nets, fields and pose
let bigger = lib.replace("(size 1 1.45)", "(size 1.2 1.6)");
let mut changed = |id: &str| -> Result<String, String> { if id.starts_with("Capacitor_SMD:") { Ok(bigger.clone()) } else { Err(format!("{id} not in this test library")) } };
let c = be::update_from_library(&e, &json!({"refs": ["C7", "U1"]}), &mut changed).unwrap();
let rows = c.report["footprints"].as_array().unwrap();
let c7 = rows.iter().find(|r| r["ref"] == "C7").unwrap();
assert_eq!(c7["status"], "differs");
assert_eq!(c7["diff"]["padsChanged"].as_array().unwrap().len(), 2);
assert_eq!(rows.iter().find(|r| r["ref"] == "U1").unwrap()["status"], "library_not_found");
let before = parse(&e);
let after = parse(&c.candidate);
let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());
assert_eq!(a7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>(), b7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>());
assert_eq!(a7.pads[0].width, Some(1.2));
assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().rotation), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().rotation));
assert_eq!(a7.uuid, b7.uuid);
assert_eq!(a7.path, b7.path);
assert_eq!(a7.properties.get("LCSC"), b7.properties.get("LCSC"));
assert_eq!(c.remove, vec![b7.uuid.clone().unwrap()]);
if let Some(rep) = kicad_loads("update", &c.candidate) {
assert!(rep.get("kicad_version").is_some());
}
}
#[test]
fn swap_footprint_puts_another_footprint_on_one_part_keeping_nets_by_number() {
let e = esc();
// C7's footprint with a third pad: a different library footprint
let lib = c7_as_library(&e);
let root = sxedit::parse(&lib).unwrap();
let pad2 = root.kids_tagged("pad").find(|p| p.atom(1) == Some("2")).unwrap().src(&lib).to_string();
let pad3 = pad2.replacen("(pad \"2\"", "(pad \"3\"", 1);
let three = lib.replacen(&pad2, &format!("{pad2}\n\t{pad3}"), 1);
let c = be::swap_footprint(&e, &json!({"ref": "C7", "assigns": "3=GND"}), &three, "Mine:C_3pad").unwrap();
assert_eq!(c.report["from"], "Capacitor_SMD:C_0805_2012Metric", "{}", c.report);
assert_eq!(c.report["to"], "Mine:C_3pad");
assert_eq!(c.report["netsKeptOnPads"], json!(["1", "2"]));
assert_eq!(c.report["padsWithoutNet"], json!([]));
let (before, after) = (parse(&e), parse(&c.candidate));
let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());
assert_eq!(a7.pads.len(), 3);
assert_eq!(a7.pads[0].net_name, b7.pads[0].net_name);
assert_eq!(a7.pads[2].net_name.as_deref(), Some("GND"));
assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().y), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().y));
assert_eq!(a7.uuid, b7.uuid);
assert!(c.candidate.contains("(footprint \"Mine:C_3pad\""));
// one part only, and an unknown net is refused
assert!(be::swap_footprint(&e, &json!({"refs": ["C7", "C8"]}), &three, "Mine:C_3pad").is_err());
assert!(be::swap_footprint(&e, &json!({"ref": "C7", "assigns": {"3": "NO_SUCH_NET"}}), &three, "Mine:C_3pad").is_err());
}
#[test]
fn footprint_graphics_and_models_list_and_edit() {
let e = esc();
let (list, none) = be::footprint_graphics(&e, &json!({"ref": "C7"})).unwrap();
assert!(none.is_none());
let g = list["graphics"].as_array().unwrap();
let silk = g.iter().find(|g| g["layer"] == "F.SilkS" && g["kind"] == "line").unwrap();
let uuid = silk["uuid"].as_str().unwrap();
let (_, c) = be::footprint_graphics(&e, &json!({"ref": "C7", "uuid": uuid, "change": {"width": 0.2}})).unwrap();
let c = c.unwrap();
assert_eq!(c.report["after"]["width"], 0.2);
assert_eq!(c.report["before"]["width"], 0.12);
let (models, _) = be::footprint_models(&e, &json!({"ref": "C7"})).unwrap();
assert_eq!(models["models"].as_array().unwrap().len(), 1);
let (_, m) = be::footprint_models(&e, &json!({"ref": "C7", "ops": [{"action": "replace", "index": 0, "offset": [0, 0, 0.1], "rotate": [0, 0, 180]}]})).unwrap();
let m = m.unwrap();
assert_eq!(m.report["after"][0]["offset"], json!([0.0, 0.0, 0.1]));
assert!(m.candidate.contains("(xyz 0 0 0.1)"));
let (_, r) = be::footprint_models(&e, &json!({"ref": "C7", "action": "remove", "index": 0})).unwrap();
assert_eq!(r.unwrap().report["after"].as_array().unwrap().len(), 0);
}
#[test]
fn cross_probe_from_reference_and_symbol_uuid_on_the_rp2040_project() {
let pcb_text = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb")).unwrap();
let sch = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_sch")).unwrap();
// The tour board was written without schematic links; give one footprint the (path ...)
// KiCad's Update PCB from Schematic writes: "/<symbol uuid>" on a root sheet.
let symbols = be::schematic_symbols(&sch);
let b = parse(&pcb_text);
let (sym_uuid, reference) = symbols.iter().find_map(|s| b.footprints.iter().any(|f| f.reference == s.1).then(|| (s.0.clone(), s.1.clone()))).expect("a symbol whose reference is on the board");
let root = sxedit::parse(&pcb_text).unwrap();
let fp = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == reference).unwrap();
let at = fp.kid("at").unwrap();
let linked_text = format!("{}\n\t\t(path \"/{sym_uuid}\")\n\t\t(sheetname \"/\")\n\t\t(sheetfile \"rp2040-breakout.kicad_sch\"){}", &pcb_text[..at.end], &pcb_text[at.end..]);
let sheets = vec![("rp2040-breakout.kicad_sch".to_string(), sch)];
let v = be::cross_probe(&linked_text, &sheets, &json!({"reference": reference})).unwrap();
assert_eq!(v["count"], 1);
let m = &v["matches"][0];
assert_eq!(m["linked"], true);
assert_eq!(m["symbolUuid"], sym_uuid.as_str());
assert_eq!(m["symbol"]["uuid"], sym_uuid.as_str(), "the schematic symbol is found by the board's path link: {v}");
assert_eq!(m["symbol"]["reference"], reference.as_str());
let back = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": sym_uuid})).unwrap();
assert_eq!(back["matches"][0]["ref"], reference.as_str());
let by_path = be::cross_probe(&linked_text, &sheets, &json!({"path": format!("/{sym_uuid}")})).unwrap();
assert_eq!(by_path["count"], 1);
// a symbol with no footprint is named as such
let orphan = symbols.iter().find(|s| !b.footprints.iter().any(|f| f.reference == s.1) && !s.1.starts_with('#'));
if let Some(o) = orphan {
let r = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": o.0})).unwrap();
assert_eq!(r["count"], 0);
assert_eq!(r["symbolWithoutFootprint"]["uuid"], o.0.as_str());
}
assert_eq!(be::cross_probe(&linked_text, &sheets, &json!({})).unwrap_err().code, "missing_arg");
}
#[test]
fn title_block_and_extents_of_the_esc() {
let e = esc();
let tb = be::title_block(&e);
assert_eq!(tb["title"], "ESC: G431");
assert_eq!(tb["rev"], "2.0.1");
assert_eq!(tb["company"], "ADOM INDUSTRIES INC.");
let x = be::extents(&parse(&e));
assert!(x["x0"].as_f64().unwrap() <= 104.0 && x["x1"].as_f64().unwrap() >= 168.0);
}
#[test]
fn closed_file_write_backs_up_refuses_open_boards_and_stale_text() {
let dir = temp_dir("write");
let path = dir.join("b.kicad_pcb");
let t = blank();
std::fs::write(&path, &t).unwrap();
let c: Change = be::outline(&t, &json!({"width": 20, "height": 20, "x": 0, "y": 0})).unwrap();
let bak = be::write_closed(&path, &t, &c.candidate).unwrap();
assert_eq!(std::fs::read_to_string(&bak).unwrap(), t);
assert_eq!(std::fs::read_to_string(&path).unwrap(), c.candidate);
// stale: the file no longer holds the text the edit was planned on
assert_eq!(be::write_closed(&path, &t, &c.candidate).unwrap_err().code, "stale_board");
std::fs::write(dir.join("~b.kicad_pcb.lck"), "x").unwrap();
assert_eq!(be::write_closed(&path, &c.candidate, &t).unwrap_err().code, "board_open");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_live_create_text_wraps_blocks_in_the_boards_header() {
let e = esc();
let w = be::create_text(&e, &["(gr_line (start 0 0) (end 1 1) (layer \"Dwgs.User\"))".to_string()]);
let root = sxedit::parse(&w).unwrap();
assert_eq!(root.tag(), Some("kicad_pcb"));
assert_eq!(root.value_of("version"), Some("20260206"));
assert!(root.kid("layers").is_some());
assert!(root.kid("gr_line").is_some());
let b = parse(&w);
assert_eq!(pcb::copper_layers(&b).len(), 4);
// what a live flip of C7 hands ParseAndCreateItemsFromString is a board KiCad reads
let flip = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap();
let w = be::create_text(&e, &flip.create);
let b = parse(&w);
assert_eq!(b.footprints.len(), 1);
assert_eq!(b.footprints[0].layer.as_deref(), Some("B.Cu"));
assert_eq!(b.footprints[0].pads.iter().filter(|p| p.net_name.is_some()).count(), 2, "the pads keep their nets by name");
if let Some(rep) = kicad_loads("create-text", &w) {
assert!(rep.get("kicad_version").is_some());
}
}
/// Pad flashes (D03) in a Gerber file, in KiCad's frame (Gerber y is up), rounded to 1 um.
fn flashes(gbr: &str) -> Vec<(i64, i64)> {
let mut out = Vec::new();
let (mut x, mut y) = (0i64, 0i64);
for line in gbr.lines() {
let l = line.trim();
if !l.ends_with("D03*") || !l.starts_with('X') && !l.starts_with('Y') {
continue;
}
let num = |tag: char| -> Option<i64> {
let i = l.find(tag)? + 1;
let s: String = l[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();
s.parse().ok()
};
if let Some(v) = num('X') { x = v; }
if let Some(v) = num('Y') { y = v; }
out.push(((x as f64 / 1000.0).round() as i64, (-(y as f64) / 1000.0).round() as i64));
}
out.sort();
out
}
fn gerber(tag: &str, text: &str, layer: &str) -> Option<String> {
let svc = service_kicad()?;
let dir = temp_dir(tag);
let path = dir.join(format!("{tag}.kicad_pcb"));
std::fs::write(&path, text).unwrap();
let out_dir = dir.join("gerbers");
let out = Command::new(&svc).args(["pcb", "export", "gerbers"]).arg(&path).arg("--out").arg(&out_dir).output().ok()?;
let want = layer.replace('.', "_");
let file = std::fs::read_dir(&out_dir).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().map(|n| n.to_string_lossy().contains(&want)).unwrap_or(false));
match file {
Some(f) => Some(std::fs::read_to_string(f).unwrap()),
None => {
eprintln!("no {layer} gerber from service-kicad for {tag}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
None
}
}
}
/// KiCad itself draws the flipped footprint's pads where the bridge mirrored them: the
/// B.Cu Gerber of the flipped part is the F.Cu Gerber mirrored about the anchor.
#[test]
fn kicad_draws_the_flipped_pads_at_the_mirrored_positions() {
let lib = "(footprint \"Asym\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -2 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15)))) (property \"Value\" \"Asym\" (at 0 2 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15)))) (attr smd) (pad \"1\" smd circle (at -1 0.5) (size 0.6 0.6) (layers \"F.Cu\" \"F.Mask\")) (pad \"2\" smd circle (at 2 -1) (size 0.4 0.4) (layers \"F.Cu\" \"F.Mask\")) (pad \"3\" smd circle (at 0.3 1.7) (size 0.5 0.5) (layers \"F.Cu\" \"F.Mask\")))";
let base = blank();
let c = be::add_footprint(&base, Some((lib, "Test:Asym")), &json!({"x": 100, "y": 100, "rotation": 30, "reference": "U1"})).unwrap();
let Some(front) = gerber("flip-front", &c.candidate, "F.Cu") else { eprintln!("service-kicad not available; skipping"); return };
let f = flashes(&front);
assert_eq!(f.len(), 3, "three pad flashes on F.Cu: {front}");
// the bridge's own pad positions agree with KiCad's drawing
let mut ours: Vec<(i64, i64)> = pcb::pads(&parse(&c.candidate)).iter().map(|p| ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)).collect();
ours.sort();
assert_eq!(ours, f);
for (dir, tag) in [("left_right", "flip-lr"), ("top_bottom", "flip-tb")] {
let flipped = be::flip_footprints(&c.candidate, &json!({"ref": "U1", "direction": dir})).unwrap();
let back = gerber(tag, &flipped.candidate, "B.Cu").expect("B.Cu gerber");
let b = flashes(&back);
let mut want: Vec<(i64, i64)> = f.iter().map(|(x, y)| if dir == "left_right" { (200_000 - x, *y) } else { (*x, 200_000 - y) }).collect();
want.sort();
assert_eq!(b, want, "{dir}: KiCad drew the flipped pads at {b:?}, the mirror is {want:?}");
}
}