master
Colby Knox Merge PR #326: kicad-lane: 1.0.76, fanout conflicts, engine 0.10.0, tour template (#136) 1e28a53 13d ago
//! 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();
}