master
John Lauer rust: phase 2 routing group on kicad-ipc-rs (routing_state, route_net, remove_route, routing_validate, board_pads, add_… ac9a1e9 2mo ago
//! File-based routing end to end: the six-net fixture from `demo/routing/make_fixture.py`
//! (rebuilt here from the blank template), every net routed through the same planner and
//! writer `kicad_route` uses, then (when `service-kicad` is on PATH) a real KiCad 10 DRC on
//! the edited board: zero unconnected items, zero errors, and the DRC delta helper the
//! live verbs use certifies that the copper added no error.

use std::path::{Path, PathBuf};
#[cfg(feature = "ipc")]
use std::process::Command;

use serde_json::{json, Value};

use kicad_core::pcb::{self, NetFormat};
use kicad_core::schematic::new_uuid;

fn repo_file(rel: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}

fn temp_dir(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("kicad-core-routing-{tag}-{}-{}", std::process::id(), new_uuid()));
    std::fs::create_dir_all(&d).unwrap();
    d
}

#[cfg(feature = "ipc")]
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())
}

/// `demo/routing/make_fixture.py`: J1 and J2 with six pads each on NET_1..NET_6, J3 with
/// one pad on NET_6, an Edge.Cuts rectangle, KiCad 10 dialect.
fn fixture_text() -> String {
    let base = std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).expect("blank-board template");
    let mut blocks = String::new();
    let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {
        blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));
        blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));
        blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");
        for (n, px, py, net) in pads {
            blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));
        }
        blocks.push_str(")\n");
    };
    let six = || (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();
    footprint("J1", 105.0, 85.0, six());
    footprint("J2", 145.0, 85.0, six());
    footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);
    blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));
    let idx = base.rfind(')').unwrap();
    format!("{}{}\n)\n", &base[..idx], blocks)
}

/// The fixture's `plan.json`: straight runs, NET_3 with a layer change, NET_6 branched to J3.
fn plan_rows() -> Vec<Value> {
    (1..=6)
        .map(|i| {
            let y = 85.0 + ((i - 1) * 5) as f64;
            let net = format!("NET_{i}");
            let points = if i == 3 {
                json!([format!("J1.{i}"), {"x": 112, "y": y, "layer": "B.Cu"}, {"x": 138, "y": y, "layer": "F.Cu"}, format!("J2.{i}")])
            } else {
                json!([format!("J1.{i}"), [112, y], [138, y], format!("J2.{i}")])
            };
            if i == 6 {
                json!({"net": net, "width": 0.25, "paths": [points, [[125, y], [125, 113], "J3.1"]]})
            } else {
                json!({"net": net, "width": 0.25, "points": points})
            }
        })
        .collect()
}

/// What `kicad_route` does per call: load, plan, splice, atomic write with backup.
fn route_on_disk(path: &Path, row: &Value) -> (usize, usize) {
    let loaded = pcb::load(&json!({"filePath": path.to_string_lossy()}), false).expect("board loads");
    let plan = pcb::plan(&loaded.board, row).expect("plan is valid");
    pcb::write_board(path, &pcb::splice(&loaded.text, &plan.blocks), true, Some(&loaded.text)).expect("write accepted");
    (plan.segments.len(), plan.vias.len())
}

#[test]
fn routes_the_six_net_fixture_on_disk() {
    let dir = temp_dir("fixture");
    let path = dir.join("live-routing.kicad_pcb");
    std::fs::write(&path, fixture_text()).unwrap();
    let before = pcb::parse_pcb(&path).unwrap();
    assert_eq!(before.net_format, NetFormat::Name);
    assert_eq!(pcb::pads(&before).len(), 13);
    assert!(before.segments.is_empty() && before.vias.is_empty());

    let mut segments = 0;
    let mut vias = 0;
    for row in plan_rows() {
        let (s, v) = route_on_disk(&path, &row);
        segments += s;
        vias += v;
    }
    // The Python demo's verified numbers: 20 segments and two through vias.
    assert_eq!((segments, vias), (20, 2));
    let after = pcb::parse_pcb(&path).unwrap();
    assert_eq!(after.segments.len(), 20);
    assert_eq!(after.vias.len(), 2);
    assert_eq!(pcb::pads(&after), pcb::pads(&before), "the splice never touches footprints");
    let bak = std::fs::read_to_string(format!("{}.adom-bak", path.display())).unwrap();
    assert_eq!(pcb::parse_pcb_text(&bak, "bak").unwrap().segments.len(), 20 - 5, "the backup is the state before the last (branched, five-segment) net");
    // A second route of an already routed pad pair still plans (the file verbs do not check
    // connectivity), but a stale `expected` text is refused.
    let stale = fixture_text();
    let plan = pcb::plan(&after, &json!({"net": "NET_1", "points": ["J1.1", "J2.1"]})).unwrap();
    let err = pcb::write_board(&path, &pcb::splice(&stale, &plan.blocks), false, Some(&stale)).unwrap_err();
    assert_eq!(err["errorCode"], "board_write_refused");
    let _ = std::fs::remove_dir_all(&dir);
}

/// Real KiCad 10 verdict through the shared service on the board the file verbs edited.
/// Needs the `ipc` feature for the DRC shaping helpers the live verbs share.
#[cfg(feature = "ipc")]
#[test]
fn service_kicad_drc_accepts_the_routed_fixture() {
    let Some(svc) = service_kicad() else {
        eprintln!("service-kicad not on PATH; skipping the live DRC test");
        return;
    };
    let dir = temp_dir("drc");
    let path = dir.join("live-routing.kicad_pcb");
    std::fs::write(&path, fixture_text()).unwrap();
    let drc = |tag: &str| -> Value {
        let report = dir.join(format!("drc-{tag}.json"));
        let out = Command::new(&svc).args(["pcb", "drc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().expect("service-kicad runs");
        assert!(report.is_file(), "service-kicad pcb drc wrote no report: {}\n{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
        let rep: Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).expect("DRC report is JSON");
        assert!(rep.get("kicad_version").is_some(), "not a DRC report: {rep}");
        rep
    };
    let unrouted = drc("before");
    let before = kicad_core::ipc::drc_result(&unrouted, vec![]);
    assert_eq!(before["unconnected"], 7, "six two-pad nets plus the NET_6 branch: {}", before["unconnected"]);
    assert_eq!(before["errors"], 0);

    for row in plan_rows() {
        route_on_disk(&path, &row);
    }
    let routed = drc("after");
    let after = kicad_core::ipc::drc_result(&routed, vec![]);
    eprintln!("service-kicad DRC on the routed fixture: {} error(s), {} warning(s), {} unconnected; kicad {}", after["errors"], after["warnings"], after["unconnected"], routed["kicad_version"]);
    for v in after["violations"].as_array().unwrap() {
        eprintln!("  {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));
    }
    assert_eq!(after["unconnected"], 0, "every net is connected by the written copper");
    assert_eq!(after["errors"], 0, "no DRC errors: {}", after["violations"]);
    // The demo's three warnings: the deliberately uninstalled RoutingFixture library.
    let warnings: Vec<&str> = after["violations"].as_array().unwrap().iter().filter_map(|v| v["type"].as_str()).collect();
    assert!(warnings.iter().all(|t| *t == "lib_footprint_issues"), "only library warnings expected: {warnings:?}");
    assert_eq!(after["clean"], false, "warnings keep clean false, as in the Python");
    assert_eq!(after["violationReportMayBeTruncated"], false);
    // The route_net preflight on these two reports certifies the copper added no error.
    assert!(kicad_core::ipc::new_errors(&before, &after).unwrap().is_empty());
    let _ = std::fs::remove_dir_all(&dir);
}