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 #326: kicad-lane: 1.0.76, fanout conflicts, engine 0.10.0, tour template (#136)
1e28a53
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
//! Schematic edit in place: lossless round trip on the two shipped tour schematics,
//! the structural effect of every operation, library resolution through a
//! sym-lib-table, and (when `service-kicad` is on PATH) a real KiCad 10 ERC on an
//! edited sheet so the edits are proven loadable by KiCad itself.
use std::path::{Path, PathBuf};
use std::process::Command;
use kicad_core::libraries::LibCtx;
use kicad_core::schematic::*;
fn repo_file(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}
fn tour_demo() -> String {
std::fs::read_to_string(repo_file("tour-pack/template/tour-demo.kicad_sch")).expect("tour-demo.kicad_sch readable")
}
fn rp2040() -> String {
std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_sch")).expect("rp2040-breakout.kicad_sch readable")
}
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("kicad-core-sch-{tag}-{}-{}", std::process::id(), new_uuid()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A two-symbol library: R plus a derived R_Derived (extends R) with its own Value.
const MINI_LIB: &str = r#"(kicad_symbol_lib
(version 20241209)
(generator "kicad_symbol_editor")
(generator_version "9.0")
(symbol "R"
(pin_numbers
(hide yes)
)
(pin_names
(offset 0)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "R"
(at 2.032 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "R"
(at 0 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" ""
(at -1.778 0 90)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Datasheet" "~"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Description" "Resistor"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "ki_keywords" "R res resistor"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(symbol "R_0_1"
(rectangle
(start -1.016 -2.54)
(end 1.016 2.54)
(stroke
(width 0.254)
(type default)
)
(fill
(type none)
)
)
)
(symbol "R_1_1"
(pin passive line
(at 0 3.81 270)
(length 1.27)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin passive line
(at 0 -3.81 90)
(length 1.27)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
(embedded_fonts no)
)
(symbol "R_Derived"
(extends "R")
(property "Reference" "R"
(at 2.032 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "R_Derived"
(at 0 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" "Resistor_SMD:R_0603_1608Metric"
(at -1.778 0 90)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
)
)
"#;
#[test]
fn round_trip_is_byte_identical() {
for (name, text) in [("tour-demo", tour_demo()), ("rp2040-breakout", rp2040())] {
let sch = Schematic::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(sch.to_text(), text, "{name}: parse + write must be byte identical");
}
}
#[test]
fn reads_what_the_sheet_holds() {
let sch = Schematic::parse(&tour_demo()).unwrap();
assert_eq!(sch.root_uuid().as_deref(), Some("29d2ee99-c9f8-4d10-bfe1-baa46f373d38"));
assert_eq!(sch.version().as_deref(), Some("20260306"));
assert!(sch.has_lib_symbol("AdomTour:R_AdomTour"));
let syms = sch.symbols();
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].reference, "R1");
assert_eq!(syms[0].at, [127.0, 81.28]);
assert_eq!(sch.project_name(), "tour-demo");
assert_eq!(sch.instance_path().unwrap(), "/29d2ee99-c9f8-4d10-bfe1-baa46f373d38");
assert_eq!(Schematic::parse(&rp2040()).unwrap().symbols().len(), 20);
}
#[test]
fn place_symbol_from_existing_lib_symbols_entry() {
let before = tour_demo();
let mut sch = Schematic::parse(&before).unwrap();
let placed = sch
.place_symbol(
&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R5", at: (140.0, 81.28), rotation: 90.0, unit: 1, value: Some("4k7"), footprint: None },
None,
)
.unwrap();
assert!(!placed.lib_symbol_added);
assert_eq!(placed.pins, vec!["1", "2"]);
let text = sch.to_text();
// Every original byte survives: the new block is inserted before sheet_instances.
let cut = before.find("\t(sheet_instances").unwrap();
assert!(text.starts_with(&before[..cut]));
assert!(text.ends_with(&before[cut..]));
let block = &text[cut..text.find("\t(sheet_instances").unwrap()];
assert!(block.starts_with("\t(symbol\n\t\t(lib_id \"AdomTour:R_AdomTour\")\n\t\t(at 140 81.28 90)\n\t\t(unit 1)\n"), "{block}");
assert!(block.contains(&format!("(uuid \"{}\")", placed.uuid)));
assert!(block.contains("(property \"Reference\" \"R5\"\n\t\t\t(at 142.54 80.01 0)"));
assert!(block.contains("(property \"Value\" \"4k7\""));
assert!(block.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\""));
assert!(block.contains("\t\t(pin \"1\"\n\t\t\t(uuid \""));
assert!(block.contains("(instances\n\t\t\t(project \"tour-demo\"\n\t\t\t\t(path \"/29d2ee99-c9f8-4d10-bfe1-baa46f373d38\"\n\t\t\t\t\t(reference \"R5\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n"));
let again = Schematic::parse(&text).unwrap();
assert_eq!(again.symbols().len(), 2);
assert_eq!(again.to_text(), text);
assert!(sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R5", at: (0.0, 0.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).is_err());
}
#[test]
fn place_symbol_copies_lib_symbol_and_flattens_extends() {
let mut sch = Schematic::parse(&tour_demo()).unwrap();
let block = flatten_lib_symbol(MINI_LIB, "R_Derived").unwrap();
assert!(block.child("extends").is_none());
assert_eq!(symbol_property(&block, "Value").as_deref(), Some("R_Derived"));
assert_eq!(symbol_property(&block, "Description").as_deref(), Some("Resistor"));
let units: Vec<String> = block.children().iter().filter(|n| n.head() == Some("symbol")).map(|n| n.arg(0).unwrap()).collect();
assert_eq!(units, vec!["R_Derived_0_1", "R_Derived_1_1"]);
let placed = sch
.place_symbol(&PlaceSymbol { lib_id: "Mini:R_Derived", reference: "R2", at: (100.0, 50.0), rotation: 0.0, unit: 1, value: None, footprint: None }, Some(&block))
.unwrap();
assert!(placed.lib_symbol_added);
assert_eq!(placed.pins, vec!["1", "2"]);
let text = sch.to_text();
assert!(text.contains("\n\t\t(symbol \"Mini:R_Derived\"\n\t\t\t(pin_numbers"), "lib_symbols entry is renamed to the lib id and indented two tabs");
assert!(text.contains("\t\t\t(symbol \"R_Derived_1_1\""));
assert!(text.contains("(property \"Value\" \"R_Derived\"\n\t\t\t(at 102.54 51.27 0)"));
assert!(text.contains("(property \"Footprint\" \"Resistor_SMD:R_0603_1608Metric\""));
assert!(!text.contains("\"ki_keywords\"\n\t\t\t(at 100"), "ki_ fields are not copied onto the instance");
let again = Schematic::parse(&text).unwrap();
assert!(again.has_lib_symbol("Mini:R_Derived"));
assert!(again.has_lib_symbol("AdomTour:R_AdomTour"));
// Placing a second one reuses the entry.
let mut again = again;
let p2 = again.place_symbol(&PlaceSymbol { lib_id: "Mini:R_Derived", reference: "R3", at: (110.0, 50.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).unwrap();
assert!(!p2.lib_symbol_added);
}
#[test]
fn wire_label_move_delete_set_property() {
let mut sch = Schematic::parse(&tour_demo()).unwrap();
let wires = sch.add_wire(&[(127.0, 77.47), (127.0, 70.0), (140.0, 70.0)]).unwrap();
assert_eq!(wires.len(), 2);
let text = sch.to_text();
assert!(text.contains("\n\t(wire\n\t\t(pts\n\t\t\t(xy 127 77.47) (xy 127 70)\n\t\t)\n\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n\t\t(uuid \""), "{text}");
assert!(sch.add_wire(&[(1.0, 1.0)]).is_err());
let l = sch.add_label("VIN", LabelKind::Local, (140.0, 70.0), 0.0, None).unwrap();
let g = sch.add_label("USB_5V", LabelKind::Global, (127.0, 85.09), 180.0, None).unwrap();
let h = sch.add_label("SDA", LabelKind::Hierarchical, (150.0, 70.0), 0.0, Some("output")).unwrap();
let text = sch.to_text();
assert!(text.contains(&format!("\n\t(label \"VIN\"\n\t\t(at 140 70 0)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid \"{l}\")\n\t)")), "{text}");
assert!(text.contains("\n\t(global_label \"USB_5V\"\n\t\t(shape bidirectional)\n\t\t(at 127 85.09 180)\n\t\t(fields_autoplaced yes)"));
assert!(text.contains(&format!("(uuid \"{g}\")\n\t\t(property \"Intersheetrefs\" \"${{INTERSHEET_REFS}}\"")));
assert!(text.contains(&format!("\n\t(hierarchical_label \"SDA\"\n\t\t(shape output)\n\t\t(at 150 70 0)\n\t\t(fields_autoplaced yes)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify left)\n\t\t)\n\t\t(uuid \"{h}\")\n\t)")));
assert!(sch.add_label("X", LabelKind::Global, (0.0, 0.0), 0.0, Some("weird")).is_err());
assert!(sch.add_label("X", LabelKind::Local, (0.0, 0.0), 45.0, None).is_err());
// Move R1 from (127, 81.28) to (150, 90): the anchor and every field shift together.
let (uuid, from) = sch.move_symbol("R1", (150.0, 90.0)).unwrap();
assert_eq!(uuid, "72781317-3f2d-4a60-bab0-9ae1064c96fe");
assert_eq!(from, (127.0, 81.28));
let text = sch.to_text();
assert!(text.contains("(lib_id \"AdomTour:R_AdomTour\")\n\t\t(at 150 90 0)"));
assert!(text.contains("(property \"Reference\" \"R1\"\n\t\t\t(at 152.54 88.73 0)"), "{text}");
assert!(text.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\"\n\t\t\t(at 150 90 0)"));
assert!(sch.move_symbol("R99", (0.0, 0.0)).is_err());
// Set an existing field, add a new one, rename the reference (instances follow).
let (u, old) = sch.set_property("R1", "Value", "22k").unwrap();
assert_eq!(u, uuid);
assert_eq!(old.as_deref(), Some("10k"));
let (_, old) = sch.set_property("R1", "MPN", "RC0805FR-0722KL").unwrap();
assert!(old.is_none());
sch.set_property("R1", "Reference", "R7").unwrap();
let text = sch.to_text();
assert!(text.contains("(property \"Value\" \"22k\""));
assert!(text.contains("\n\t\t(property \"MPN\" \"RC0805FR-0722KL\"\n\t\t\t(at 150 90 0)\n\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t\t(hide yes)\n\t\t\t)\n\t\t)\n\t\t(pin \"1\""), "{text}");
assert!(text.contains("(property \"Reference\" \"R7\""));
assert!(text.contains("(reference \"R7\")"));
assert!(!text.contains("\"R1\""));
assert_eq!(sch.symbols()[0].value, "22k");
assert!(sch.set_property("R7", "Reference", "").is_err());
// Delete by uuid: a wire, a label and the symbol.
assert_eq!(sch.delete(&wires[0]).unwrap(), "wire");
assert_eq!(sch.delete(&g).unwrap(), "global_label");
assert_eq!(sch.delete(&uuid).unwrap(), "symbol");
assert!(sch.delete(&uuid).is_err());
let text = sch.to_text();
assert!(!text.contains(&wires[0]) && !text.contains(&g) && !text.contains(&uuid));
assert!(text.contains(&wires[1]) && text.contains(&l) && text.contains(&h));
assert!(sch.symbols().is_empty());
assert!(text.ends_with("\t(sheet_instances\n\t\t(path \"/\"\n\t\t\t(page \"1\")\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n"));
Schematic::parse(&text).unwrap();
}
#[test]
fn next_reference_skips_used_numbers() {
let mut sch = Schematic::parse(&tour_demo()).unwrap();
assert_eq!(sch.next_reference("R"), "R2");
assert_eq!(sch.next_reference("#PWR"), "#PWR01");
sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R2", at: (0.0, 0.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).unwrap();
assert_eq!(sch.next_reference("R"), "R3");
}
#[test]
fn resolves_lib_id_through_sym_lib_tables() {
let dir = temp_dir("libs");
let lib = dir.join("Mini.kicad_sym");
std::fs::write(&lib, MINI_LIB).unwrap();
// Global table (config dir) names it Mini; a project table next to the sheet names
// the same file Local via ${KIPRJMOD}.
let cfg = dir.join("cfg");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(cfg.join("sym-lib-table"), format!("(sym_lib_table\n (version 7)\n (lib (name \"Mini\")(type \"KiCad\")(uri \"{}\")(options \"\")(descr \"\"))\n)\n", lib.to_string_lossy().replace('\\', "/"))).unwrap();
let proj = dir.join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Local\")(type \"KiCad\")(uri \"${KIPRJMOD}/../Mini.kicad_sym\")(options \"\")(descr \"\"))\n)\n").unwrap();
let ctx = LibCtx { version: "10.0".into(), config_dir: Some(cfg), ..Default::default() };
let r = resolve_lib_symbol(&ctx, "Mini:R", Some(&proj)).unwrap();
assert_eq!(r.name, "R");
assert_eq!(r.tables_checked.len(), 2);
let r = resolve_lib_symbol(&ctx, "Local:R_Derived", Some(&proj)).unwrap();
assert!(r.block.child("extends").is_none());
assert!(r.library_path.ends_with("Mini.kicad_sym"));
let e = resolve_lib_symbol(&ctx, "Nope:R", Some(&proj)).unwrap_err();
assert!(e.contains("not in any sym-lib-table"), "{e}");
let e = resolve_lib_symbol(&ctx, "Mini:Missing", None).unwrap_err();
assert!(e.contains("not found"), "{e}");
assert!(resolve_lib_symbol(&ctx, "no-colon", None).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_with_backup_writes_bak_next_to_file() {
let dir = temp_dir("save");
let path = dir.join("tour-demo.kicad_sch");
std::fs::write(&path, tour_demo()).unwrap();
std::fs::write(dir.join("tour-demo.kicad_pro"), "{}").unwrap();
let mut sch = Schematic::load(&path).unwrap();
assert_eq!(sch.project_name(), "tour-demo");
sch.add_wire(&[(0.0, 0.0), (10.0, 0.0)]).unwrap();
let bak = sch.save_with_backup(&path).unwrap();
assert_eq!(bak, dir.join("tour-demo.kicad_sch.bak"));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), tour_demo());
assert_eq!(std::fs::read_to_string(&path).unwrap(), sch.to_text());
let _ = std::fs::remove_dir_all(&dir);
}
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())
}
/// Real KiCad 10 verdict on an edited sheet: place_symbol (from a stock library fetched
/// through the service), wire, label, then `service-kicad sch erc`. The report must
/// parse and mention the placed reference; KiCad refusing the file would fail here.
#[test]
fn service_kicad_erc_accepts_the_edited_sheet() {
let Some(svc) = service_kicad() else {
eprintln!("service-kicad not on PATH; skipping the live ERC test");
return;
};
let dir = temp_dir("erc");
let sch_path = dir.join("tour-demo.kicad_sch");
std::fs::write(&sch_path, tour_demo()).unwrap();
std::fs::copy(repo_file("tour-pack/template/tour-demo.kicad_pro"), dir.join("tour-demo.kicad_pro")).unwrap();
let device = dir.join("Device.kicad_sym");
let fetched = Command::new(&svc).args(["sym", "fetch", "Device", "--out"]).arg(&device).output().expect("service-kicad runs");
assert!(fetched.status.success() && device.is_file(), "sym fetch Device: {}", String::from_utf8_lossy(&fetched.stderr));
let mut sch = Schematic::load(&sch_path).unwrap();
let r = load_lib_symbol(&device, "R").unwrap();
let placed = sch
.place_symbol(&PlaceSymbol { lib_id: "Device:R", reference: "R5", at: (140.97, 81.28), rotation: 0.0, unit: 1, value: Some("4k7"), footprint: Some("Resistor_SMD:R_0603_1608Metric") }, Some(&r.block))
.unwrap();
assert!(placed.lib_symbol_added);
// Wire the two resistors in series and label the free ends so ERC sees nets.
sch.add_wire(&[(127.0, 77.47), (127.0, 72.39), (140.97, 72.39), (140.97, 77.47)]).unwrap();
sch.add_wire(&[(127.0, 85.09), (127.0, 90.17)]).unwrap();
sch.add_wire(&[(140.97, 85.09), (140.97, 90.17)]).unwrap();
sch.add_label("VIN", LabelKind::Local, (127.0, 90.17), 0.0, None).unwrap();
sch.add_label("VOUT", LabelKind::Global, (140.97, 90.17), 0.0, None).unwrap();
sch.set_property("R1", "Value", "22k").unwrap();
sch.save_with_backup(&sch_path).unwrap();
let report = dir.join("erc.json");
let out = Command::new(&svc).args(["sch", "erc"]).arg(&sch_path).args(["--format", "json", "--out"]).arg(&report).output().expect("service-kicad runs");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(report.is_file(), "service-kicad sch erc wrote no report: {stderr}\n{}", String::from_utf8_lossy(&out.stdout));
let rep: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).expect("ERC report is JSON");
assert!(rep.get("kicad_version").is_some(), "not an ERC report: {rep}");
let flat = kicad_core::cli::flatten_violations(&rep);
let text = serde_json::to_string(&rep).unwrap();
assert!(text.contains("R5"), "KiCad loaded the placed symbol (R5 appears in the report): {text}");
let summary = kicad_core::cli::summarize_report(&rep);
eprintln!("service-kicad ERC on the edited sheet: {} error(s), {} warning(s), {} finding(s); kicad {}", summary["errors"], summary["warnings"], flat.len(), rep["kicad_version"]);
for v in &flat {
eprintln!(" {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));
}
// The series wires connect R1 pin 1 to R5 pin 1 and each free pin to a label: no
// pin_not_connected may remain.
let unconnected: Vec<&serde_json::Value> = flat.iter().filter(|v| v["type"] == "pin_not_connected").collect();
assert!(unconnected.is_empty(), "wires and labels did not connect the pins: {unconnected:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Writes tour-pack/template (wiki #136: the template was lost; the default kicad-tour pack and the tests
/// above read it): tour-demo.kicad_sch with one AdomTour:R_AdomTour (R1, 10k) at (127, 81.28), a 30 x 20 mm
/// tour-demo.kicad_pcb with R1's footprint, and tour-demo.kicad_pro. Run once with
/// `cargo test -p kicad-core --test schematic_edit -- --ignored write_tour_demo_template`.
#[test]
#[ignore]
fn write_tour_demo_template() {
let pack = repo_file("tour-pack");
let dir = pack.join("template");
std::fs::create_dir_all(&dir).unwrap();
let lib = std::fs::read_to_string(pack.join("AdomTour.kicad_sym")).unwrap();
let (text, root) = kicad_core::project::blank_schematic(Some("Adom Tour"));
let mut sch = Schematic::parse(&text).unwrap();
sch.project_hint = Some("tour-demo".into());
let block = flatten_lib_symbol(&lib, "R_AdomTour").unwrap();
sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R1", at: (127.0, 81.28), rotation: 0.0, unit: 1, value: Some("10k"), footprint: None }, Some(&block)).unwrap();
std::fs::write(dir.join("tour-demo.kicad_sch"), sch.to_text()).unwrap();
std::fs::write(dir.join("tour-demo.kicad_pro"), kicad_core::project::project_json("tour-demo", &root, &Default::default())).unwrap();
let fp = std::fs::read_to_string(pack.join("AdomTour.pretty/R_0805_AdomTour.kicad_mod")).unwrap();
let blank = kicad_core::project::blank_board(Some("Adom Tour"), 2).unwrap();
let (outline, _) = kicad_core::board_build::outline_items(&serde_json::json!({"rect": [112.0, 71.28, 30.0, 20.0], "cornerRadius": 1.0}), false).unwrap();
let part = kicad_core::board_build::Placed { reference: "R1".into(), lib_id: "AdomTour:R_0805_AdomTour".into(), text: fp, x: 127.0, y: 81.28, rotation: 0.0, back: false, value: Some("10k".into()) };
let (pcb, _) = kicad_core::board_build::build(&blank, &outline, &[part], &Default::default()).unwrap();
std::fs::write(dir.join("tour-demo.kicad_pcb"), pcb).unwrap();
}