← Commit history
bridge.json+2
@@ -147,6 +147,7 @@     "kicad_bodies",     "kicad_board_summary",     "kicad_list_nets",+    "kicad_net_connections",     "kicad_list_designators",     "kicad_copper_layers",     "kicad_layers_in_use",@@ -167,6 +168,7 @@     "kicad_update_footprints_from_library",     "kicad_swap_footprint",     "kicad_edit_pours",+    "kicad_remove_items",     "kicad_plate_vias",     "kicad_normalize_bodies",     "kicad_symbol_mode",
rust/Cargo.lock+3−3
@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.95"+version = "1.0.96" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.95"+version = "1.0.96" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.95"+version = "1.0.96" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.95"+version = "1.0.96" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_board.rs+15
@@ -147,6 +147,16 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_move_footprint", "kicad_placement_state", "kicad_placement_validate"],         pitfalls: &["Live only for a real move (dryRun works on a closed file), like kicad_move_footprint.", "Rotation and side are kept; align parts of the same orientation for a clean row."],     },+    Verb {+        name: "kicad_remove_items",+        summary: "Delete specific free tracks, arcs and vias given by their geometry, exactly as kicad_routing_state {model:\"standard\"} reported them, as one Undo step (the standard's remove_items).",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"tracks\"?: [{\"start\": {\"x\", \"y\"}, \"end\": {\"x\", \"y\"}, \"layer\"?: \"F.Cu\"}], \"vias\"?: [{\"x\", \"y\"} | {\"position\": {\"x\", \"y\"}}], \"expectedRevision\"?, \"frame\"?: \"standard\"|\"kicad\", \"dryRun\"?}",+        example: "kicad_remove_items {\"filePath\":\"C:/p/b.kicad_pcb\",\"vias\":[{\"x\":120.5,\"y\":-80.25}]}",+        hint: "results[] has one entry per item, in order (tracks, then vias): ok, or err:not found. Coordinates are mm in the standard's y-up frame (frame:\"kicad\" for KiCad's y-down), matched within 1 um; a track matches either way round. On a board open in the PCB editor it is one Undo step; a closed board is written with a .adom-bak. expectedRevision guards against a board that changed since you read it.",+        related: &["kicad_routing_state", "kicad_remove_route", "kicad_modify_track"],+        pitfalls: &["Only free copper: pads and pours are not removed (kicad_delete_footprint, kicad_remove_zone)."],+    },     Verb {         name: "kicad_edit_pours",         summary: "Change existing copper pours (zones) in place without redrawing them: clearance, minimum width, thermal reliefs (on/off, spoke width, gap) or the pad connection, priority, the net, and whether islands are kept; then refill (the standard's edit_pours).",@@ -246,6 +256,11 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_update_footprints_from_library" => update_from_library(state, args),         "kicad_swap_footprint" => swap_footprint(state, args),         "kicad_edit_pours" => edit_pours(state, args),+        "kicad_remove_items" => {+            let mut a = args.clone();+            a["_revisionOptional"] = json!(true);+            edit(state, &a, &|t| board_edit::remove_items(t, &a))+        }         "kicad_footprint_graphics" => listed_edit(state, args, Listed::Graphics),         "kicad_footprint_models" => listed_edit(state, args, Listed::Models),         "kicad_get_selection" => live_only(state, args, LiveOnly::GetSelection),
rust/crates/kicad-bridge/src/verbs_sch_hier.rs+49−5
@@ -1195,11 +1195,50 @@ fn doc_params(args: &Value) -> Value {             params.insert(format!("Comment{n}"), v.clone());         }     }-    out["parameters"] = Value::Object(params);-    out["notStored"] = json!(not_stored);-    if !not_stored.is_empty() {-        out["_hint"] = json!("KiCad's title block has Title, Revision, Date, Company and Comment1-9 only; the names in notStored were not written. Put them in a comment line, or in the project's text variables.");+    // parameters the title block has no field for live in the project's text variables, which the title block and+    // the sheets can show as ${Name} (the standard's doc_params keeps any parameter)+    let sheet = PathBuf::from(a["filePath"].as_str().unwrap_or(""));+    let pro = std::fs::read_dir(sheet.parent().unwrap_or(std::path::Path::new("."))).ok()+        .and_then(|rd| rd.flatten().map(|e| e.path()).find(|p| p.extension().map_or(false, |x| x == "kicad_pro")));+    let dry = a.get("dryRun") == Some(&json!(true));+    let mut vars_written = Vec::new();+    if let Some(pro) = &pro {+        let mut pv = kicad_core::project_rules::read_json(pro).unwrap_or(json!({}));+        if !not_stored.is_empty() {+            let set = args.get("set").and_then(Value::as_object).cloned().unwrap_or_default();+            if !pv["text_variables"].is_object() {+                pv["text_variables"] = json!({});+            }+            for k in &not_stored {+                pv["text_variables"][k.as_str()] = json!(set[k].as_str().map(String::from).unwrap_or_else(|| set[k].to_string()));+                vars_written.push(k.clone());+            }+            if !dry {+                if let Err(e) = kicad_core::project_rules::write_atomic(pro, &kicad_core::project_rules::project_text(&pv), None) {+                    return fail(&e.code, e.message, "Close the project in KiCad (it holds the .kicad_pro) and set them again.");+                }+            }+        }+        if let Some(vars) = pv["text_variables"].as_object() {+            for (k, v) in vars {+                params.entry(k.clone()).or_insert(v.clone());+            }+        }     }+    let stored_nowhere: Vec<String> = if pro.is_some() { Vec::new() } else { not_stored.clone() };+    out["params"] = json!(params.iter().map(|(k, v)| json!({"name": k, "value": v})).collect::<Vec<_>>());+    out["parameters"] = Value::Object(params);+    out["set"] = json!(args.get("set").and_then(Value::as_object).map_or(0, |m| m.len() - stored_nowhere.len()));+    out["saved"] = json!(!dry);+    out["textVariables"] = json!(vars_written);+    out["notStored"] = json!(stored_nowhere);+    out["_hint"] = json!(if !stored_nowhere.is_empty() {+        "KiCad's title block has Title, Revision, Date, Company and Comment1-9; the names in notStored were not written because no .kicad_pro sits beside the sheet to hold them as text variables."+    } else if !vars_written.is_empty() {+        "Title-block fields went into the sheet; the other names became project text variables (textVariables), which the title block or any text shows as ${Name}."+    } else {+        "params lists the title block and the project's text variables."+    });     out } @@ -1545,7 +1584,12 @@ mod param_tests {         assert_eq!(d["parameters"]["Title"], "Params test");         assert_eq!(d["parameters"]["Revision"], "C");         assert_eq!(d["parameters"]["Comment2"], "two");-        assert_eq!(d["notStored"], json!(["DrawnBy"]));+        // DrawnBy has no title-block field: it is kept as a project text variable, and read back with the rest+        assert_eq!((d["notStored"].clone(), d["textVariables"].clone(), d["set"].as_u64()), (json!([]), json!(["DrawnBy"]), Some(4)), "{d}");+        let again = call("kicad_doc_params", json!({"sheet": norm(&root)}));+        assert!(again["params"].as_array().unwrap().iter().any(|p| p["name"] == "DrawnBy" && p["value"] == "x"), "{again}");+        let pv: Value = serde_json::from_str(&std::fs::read_to_string(&pro).unwrap()).unwrap();+        assert_eq!(pv["text_variables"]["DrawnBy"], "x");     } } 
rust/crates/kicad-bridge/src/verbs_standard.rs+11
@@ -52,6 +52,16 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],         pitfalls: &["A file read does not see unsaved edits in an open editor."],     },+    Verb {+        name: "kicad_net_connections",+        summary: "Every pad on the board with its net and owner (U1.3), and per net its span and airline length: the minimum spanning tree over its pads, the shortest copper a router could need (the standard's net_connections).",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"net\"?: \"GND\", \"limit\"?: 4000, \"source\"?: \"auto|live|file\"}",+        example: "kicad_net_connections {\"filePath\":\"C:/p/b.kicad_pcb\",\"net\":\"VBUS\"}",+        hint: "data.pads[] are {net, pad, xMil, yMil} and data.nets[] {net, pads, minXMil..maxYMil, airlineMm}, in the standard's y-up frame. kicad_trace_net follows the copper actually routed.",+        related: &["kicad_list_nets", "kicad_trace_net", "kicad_net_lengths", "kicad_routing_state"],+        pitfalls: &["Pads on no net are left out."],+    },     Verb {         name: "kicad_list_nets",         summary: "Every net on the board with its pad count.",@@ -181,6 +191,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_bodies" => bodies(state, args),         "kicad_board_summary" => view(state, args, "board_summary"),         "kicad_list_nets" => view(state, args, "list_nets"),+        "kicad_net_connections" => view(state, args, "net_connections"),         "kicad_list_designators" => view(state, args, "list_designators"),         "kicad_copper_layers" => view(state, args, "copper_layers"),         "kicad_layers_in_use" => view(state, args, "layers_in_use"),
rust/crates/kicad-core/src/board_edit.rs+81
@@ -1816,6 +1816,74 @@ pub fn footprint_primitives(text: &str, reference: &str) -> RResult<Value> {         "at": [mil(x), mil(y)], "rotation": rot, "side": fp_text::side(fp), "units": "mil (KiCad's board frame, y down)"})) } +// -- remove_items -------------------------------------------------------------------------------++/// A point as the standard gives it, `{x, y}` or `[x, y]`, in KiCad's frame (y down): the standard frame's y is negated.+fn std_point(v: &Value, y_up: bool) -> Option<(f64, f64)> {+    let (x, y) = match v {+        Value::Array(a) if a.len() >= 2 => (a[0].as_f64()?, a[1].as_f64()?),+        Value::Object(_) => (v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),+        _ => return None,+    };+    Some((x, if y_up { -y } else { y }))+}++/// `kicad_remove_items`: delete free tracks (and arcs) and vias given by their geometry, as kicad_routing_state+/// reported them (the standard's y-up frame unless `frame: "kicad"`). One result per item: ok, or err:not found.+pub fn remove_items(text: &str, args: &Value) -> RResult<Change> {+    let y_up = args.get("frame").and_then(Value::as_str) != Some("kicad");+    let root = parse_root(text)?;+    let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;+    let xy = |n: &Node, tag: &str| n.kid(tag).and_then(|k| Some((k.f64_at(1)?, k.f64_at(2)?)));+    let mut e = Edits::new();+    let (mut remove, mut results, mut taken) = (Vec::new(), Vec::new(), Vec::<usize>::new());+    let tracks = args.get("tracks").and_then(Value::as_array).cloned().unwrap_or_default();+    let vias = args.get("vias").and_then(Value::as_array).cloned().unwrap_or_default();+    if tracks.is_empty() && vias.is_empty() {+        return Err(err("invalid_args", "tracks or vias is required: [{start{x,y}, end{x,y}, layer}] / [{x,y}]"));+    }+    for t in &tracks {+        let (Some(a), Some(b)) = (t.get("start").and_then(|p| std_point(p, y_up)), t.get("end").and_then(|p| std_point(p, y_up))) else {+            results.push(json!("err:bad item (start and end are required)"));+            continue;+        };+        let layer = t.get("layer").and_then(Value::as_str);+        let hit = root.kids.iter().enumerate().find(|(i, k)| {+            !taken.contains(i) && matches!(k.tag(), Some("segment") | Some("arc")) && layer.map_or(true, |l| k.value_of("layer") == Some(l))+                && match (xy(k, "start"), xy(k, "end")) { (Some(s), Some(en)) => (near(s, a) && near(en, b)) || (near(s, b) && near(en, a)), _ => false }+        });+        push_hit(hit, &mut e, text, &mut remove, &mut results, &mut taken);+    }+    for v in &vias {+        let Some(p) = std_point(v.get("position").unwrap_or(v), y_up) else {+            results.push(json!("err:bad item (x and y are required)"));+            continue;+        };+        let hit = root.kids.iter().enumerate().find(|(i, k)| !taken.contains(i) && k.tag() == Some("via") && xy(k, "at").map_or(false, |q| near(q, p)));+        push_hit(hit, &mut e, text, &mut remove, &mut results, &mut taken);+    }+    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+    let removed = remove.len();+    Ok(Change {+        candidate, remove, copper: true,+        report: json!({"results": results, "removed": removed}),+        label: format!("Adom remove {removed} item(s)"),+        ..Default::default()+    })+}++fn push_hit(hit: Option<(usize, &Node)>, e: &mut Edits, text: &str, remove: &mut Vec<String>, results: &mut Vec<Value>, taken: &mut Vec<usize>) {+    match hit.and_then(|(i, k)| k.uuid().map(|u| (i, k, u.to_string()))) {+        Some((i, k, u)) => {+            e.remove_node(text, k);+            remove.push(u);+            taken.push(i);+            results.push(json!("ok"));+        }+        None => results.push(json!("err:not found")),+    }+}+ // -- set_track_width ----------------------------------------------------------------------------  /// A track or arc's net name: KiCad 10's `(net "GND")`, or an older file's `(net 3)` through the board's net table.@@ -2004,6 +2072,19 @@ mod tests {         assert_eq!(footprint_primitives(t, "U9").unwrap_err().code, "part_not_found");     } +    #[test]+    fn remove_items_deletes_tracks_and_vias_by_their_geometry() {+        let t = "(kicad_pcb (version 20260206)\n\t(segment (start 10 20) (end 15 20) (width 0.2) (layer \"F.Cu\") (net \"A\") (uuid \"s1\"))\n\t(segment (start 15 20) (end 15 25) (width 0.2) (layer \"B.Cu\") (net \"A\") (uuid \"s2\"))\n\t(via (at 15 20) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"A\") (uuid \"v1\"))\n)\n";+        // the standard's frame is y up: (10, -20) is KiCad's (10, 20); a track may be given end first+        let c = remove_items(t, &json!({"tracks": [{"start": {"x": 15, "y": -20}, "end": {"x": 10, "y": -20}, "layer": "F.Cu"}, {"start": [0, 0], "end": [1, 1]}], "vias": [{"position": {"x": 15, "y": -20}}]})).unwrap();+        assert_eq!(c.report["results"], json!(["ok", "err:not found", "ok"]), "{}", c.report);+        assert_eq!(c.remove, vec!["s1".to_string(), "v1".to_string()]);+        assert!(!c.candidate.contains("\"s1\"") && !c.candidate.contains("\"v1\"") && c.candidate.contains("\"s2\""));+        let k = remove_items(t, &json!({"frame": "kicad", "tracks": [{"start": [15, 20], "end": [15, 25]}]})).unwrap();+        assert_eq!(k.remove, vec!["s2".to_string()]);+        assert_eq!(remove_items(t, &json!({})).unwrap_err().code, "invalid_args");+    }+     #[test]     fn set_track_width_changes_the_named_nets_tracks_on_the_layer_asked() {         let t = "(kicad_pcb (version 20260206)\n\t(segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net \"VBUS\") (uuid \"s1\"))\n\t(segment (start 1 0) (end 2 0) (width 0.2) (layer \"B.Cu\") (net \"VBUS\") (uuid \"s2\"))\n\t(arc (start 2 0) (mid 2.5 0.5) (end 3 0) (width 0.25) (layer \"F.Cu\") (net \"VBUS\") (uuid \"a1\"))\n\t(segment (start 0 5) (end 1 5) (width 0.2) (layer \"F.Cu\") (net \"SIG\") (uuid \"s3\"))\n)\n";
rust/crates/kicad-core/src/board_model.rs+61
@@ -1068,6 +1068,51 @@ pub fn derive(model: &Value, view: &str, args: &Value) -> Result<Value, (String,             for p in &pads { if let Some(c) = by.get_mut(p["net"].as_str().unwrap_or("")) { *c += 1 } }             json!({"count": by.len(), "nets": by.iter().map(|(n, c)| json!({"name": n, "pads": c})).collect::<Vec<_>>()})         }+        // the standard's net_connections: every pad with its net and owner, and per net its span and airline length+        // (a minimum spanning tree over its pads, the length a perfect router would need)+        "net_connections" => {+            let want = args.get("net").and_then(Value::as_str);+            let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(4000) as usize;+            let mil = |v: &Value| (f(v) / 0.0254 * 100.0).round() / 100.0;+            let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();+            let mut rows = Vec::new();+            for p in &pads {+                let net = p["net"].as_str().unwrap_or("");+                if net.is_empty() || want.map_or(false, |w| !w.eq_ignore_ascii_case(net)) {+                    continue;+                }+                by.entry(net.to_string()).or_default().push((f(&p["x"]), f(&p["y"])));+                rows.push(json!({"net": net, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "xMil": mil(&p["x"]), "yMil": mil(&p["y"])}));+            }+            if let Some(w) = want {+                if by.is_empty() {+                    return Err(("unknown_net".into(), format!("no pad is on net {w}")));+                }+            }+            let count = rows.len();+            rows.truncate(limit);+            let nets: Vec<Value> = by.iter().map(|(n, pts)| {+                let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+                let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+                // Prim's minimum spanning tree over the pads+                let mut inside = vec![false; pts.len()];+                let mut best = vec![f64::MAX; pts.len()];+                let mut total = 0.0;+                best[0] = 0.0;+                for _ in 0..pts.len() {+                    let Some(i) = (0..pts.len()).filter(|i| !inside[*i]).min_by(|a, b| best[*a].total_cmp(&best[*b])) else { break };+                    inside[i] = true;+                    total += best[i];+                    for j in 0..pts.len() {+                        if !inside[j] {+                            best[j] = best[j].min(((pts[i].0 - pts[j].0).powi(2) + (pts[i].1 - pts[j].1).powi(2)).sqrt());+                        }+                    }+                }+                json!({"net": n, "pads": pts.len(), "minXMil": mil(&json!(x0)), "minYMil": mil(&json!(y0)), "maxXMil": mil(&json!(x1)), "maxYMil": mil(&json!(y1)), "airlineMm": r6(total)})+            }).collect();+            json!({"data": {"count": count, "pads": rows, "nets": nets}, "truncated": count > limit})+        }         "list_designators" => {             let mut d: Vec<&str> = comps.iter().filter_map(|c| c["ref"].as_str()).collect();             d.sort_by_key(|r| natural_key(r));@@ -1795,6 +1840,22 @@ mod tests {         assert!((d["layers"][0]["dielectricUm"].as_f64().unwrap() - 1530.0).abs() < 1e-6);     } +    #[test]+    fn net_connections_lists_pads_and_each_nets_airline_length() {+        let m = json!({"components": [], "copperLayers": ["F.Cu", "B.Cu"], "nets": ["A", "B"], "pads": [+            {"ref": "U1", "name": "1", "x": 0.0, "y": 0.0, "net": "A"}, {"ref": "R1", "name": "2", "x": 3.0, "y": 0.0, "net": "A"},+            {"ref": "C1", "name": "1", "x": 3.0, "y": 4.0, "net": "A"}, {"ref": "U1", "name": "2", "x": 1.0, "y": 1.0, "net": "B"},+            {"ref": "H1", "name": "", "x": 9.0, "y": 9.0, "net": ""}]});+        let v = derive(&m, "net_connections", &json!({})).unwrap();+        assert_eq!(v["data"]["count"], 4, "pads on no net are left out: {v}");+        let a = v["data"]["nets"].as_array().unwrap().iter().find(|n| n["net"] == "A").unwrap().clone();+        assert_eq!((a["pads"].as_u64(), a["airlineMm"].as_f64(), a["maxYMil"].as_f64()), (Some(3), Some(7.0), Some(157.48)), "{a}");+        assert_eq!(v["data"]["pads"][1]["pad"], "R1.2");+        let only = derive(&m, "net_connections", &json!({"net": "b"})).unwrap();+        assert_eq!(only["data"]["count"], 1);+        assert_eq!(derive(&m, "net_connections", &json!({"net": "Z"})).unwrap_err().0, "unknown_net");+    }+     #[test]     fn pours_report_the_copper_poured() {         let v = pours(&board("(zone (net \"G\") (name \"gnd\") (layer \"F.Cu\") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))\
rust/crates/kicad-core/src/ipc/board_live.rs+12−1
@@ -50,8 +50,19 @@ fn drc_gate(ctx: &Ctx, s: &Session, text: &str, change: &Change, args: &Value) - /// it, commit it as one Undo step, read it back. pub fn commit_change(ctx: &Ctx, args: &Value, build: impl FnOnce(&str) -> RResult<Change>) -> RResult<Value> {     let s = connect(ctx, args)?;-    requires_revision(args)?;     let (text, _board, revision) = s.snapshot()?;+    // a verb whose standard form makes expectedRevision optional (`_revisionOptional`) pins the revision read here,+    // so a board that changes while the edit is prepared is still refused+    let pinned;+    let args = if args.get("_revisionOptional") == Some(&json!(true)) && args.get("expectedRevision").and_then(Value::as_str).filter(|r| !r.is_empty()).is_none() {+        let mut a = args.clone();+        a["expectedRevision"] = json!(revision);+        pinned = a;+        &pinned+    } else {+        args+    };+    requires_revision(args)?;     check_revision(args, &revision)?;     let change = build(&text)?;     commit_built(ctx, args, &s, &text, &revision, change, None)
skills/kicad-bridge-test/run_verb_tests.py+15−1
@@ -206,7 +206,7 @@ def matrix(w: dict) -> dict:             "library_views", "generate_body", "plan_route", "export_spice", "simulate",             "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs",             "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",-            "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind")},+            "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),@@ -1776,6 +1776,20 @@ def main():         kinds = rk.get("rulesByKind") or {}         own("rules_by_kind", rk, ok(rk) and "Clearance" in kinds and "Width" in kinds and all(r.get("priority") == i + 1 for l in kinds.values() for i, r in enumerate(l)),             "the rules did not come back grouped by kind in priority order")+        # 1.0.96: net_connections (pads per net and airline lengths) and remove_items (a via and a track by geometry,+        # as routing_state reports them) on the set_track_width copy+        tb = twd + "/route-fixture.kicad_pcb"+        nc = C("net_connections", {"filePath": tb, "net": "SIG_A", "source": "file"}, 120)+        ncd = nc.get("data") or {}+        own("net_connections", nc, ok(nc) and ncd.get("count", 0) >= 2 and all(p.get("net") == "SIG_A" for p in ncd.get("pads") or []) and (ncd.get("nets") or [{}])[0].get("airlineMm", 0) > 0,+            "SIG_A's pads and airline length did not come back")+        st0 = ab(a.target, "kicad_routing_state", {"filePath": tb, "source": "file", "model": "standard"}, timeout=120)+        tr0 = (st0.get("tracks") or [None])[0]+        n0 = len(st0.get("tracks") or [])+        ri = C("remove_items", {"filePath": tb, "source": "file", "tracks": [{"start": tr0["start"], "end": tr0["end"], "layer": tr0.get("layer")}] if tr0 else [], "vias": [{"x": -999, "y": -999}]}, 200)+        st1 = ab(a.target, "kicad_routing_state", {"filePath": tb, "source": "file", "model": "standard"}, timeout=120)+        own("remove_items", {"remove": ri, "tracksBefore": n0, "tracksAfter": len(st1.get("tracks") or [])}, ok(ri) and ri.get("results") == ["ok", "err:not found"] and len(st1.get("tracks") or []) == n0 - 1,+            "the track was not removed by its geometry, or the missing via was not reported")         eb = C("export_bom", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "outputDir": w["out"] + "/bom-std"}, 200)         own("export_bom", eb, ok(eb) and eb.get("files") and eb.get("surface") == "bom", "no BOM file was written")         od = C("open_document", {"filePath": xd + "/rp2040-breakout.kicad_pcb"}, 200)