← Commit history
bridge.json+1
@@ -270,6 +270,7 @@     "kicad_sheet_params",     "kicad_variants",     "kicad_sch_zoom",+    "kicad_animate_view",     "kicad_silk_state",     "kicad_symbol_prims",     "kicad_xcopy_footprint",
rust/Cargo.lock+4−4
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.20.0"+version = "0.21.1" dependencies = [  "roxmltree",  "serde_json",@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.88"+version = "1.0.89" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.88"+version = "1.0.89" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.88"+version = "1.0.89" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.88"+version = "1.0.89" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_lint.rs+133−2
@@ -37,7 +37,7 @@ pub static VERBS: &[Verb] = &[     },     Verb {         name: "kicad_lint_library",-        summary: "Lint a symbol/footprint library via kicad-cli.",+        summary: "Lint a symbol/footprint library: the bridge's structural check, KiCad's own verdict (kicad-cli), and the standard's content rules (pads, courtyard, origin, silkscreen, 3D body, description; pins, footprint link, pins against pads, part number), each finding with its rule and severity.",         mechanism: Mechanism::Cli, risk: "read", timeout_sec: 130,         input: "{\"libraryPath\": \"C:/.../x.kicad_sym | x.kicad_mod | Lib.pretty | dir\"} (filePath accepted too)",         example: "kicad_lint_library {\"libraryPath\":\"C:/Users/john/libs/Adom.kicad_sym\"}",@@ -141,7 +141,138 @@ fn lint_library(state: &mut State, args: &Value) -> Value {         );     };     let cli = state.kicad_info().primary().and_then(|i| lint::cli_exe(&i.kicad_cli, &i.bin_dir));-    lint::lint_library(Path::new(file_path), cli.as_deref(), lint::VERDICT_TIMEOUT)+    let mut v = lint::lint_library(Path::new(file_path), cli.as_deref(), lint::VERDICT_TIMEOUT);+    if v["success"] != true {+        return v;+    }+    // the standard's lint_library (#28): its content rules over what this reader parses, with KiCad's own refusals+    // passed through as tool_refuses_file; the reply keeps the structural check and adds findings by rule+    let mut library = engine_library(&v);+    // a symbol library alone: its symbols' linked footprints ("Lib:Name") come from a Lib.pretty beside it, else+    // KiCad's footprint library tables, so pins can be checked against pads; their own footprint findings are not+    // this library's and are dropped below+    let mut referenced: std::collections::BTreeSet<String> = Default::default();+    if library.get("footprints").is_none() {+        let ctx = crate::verbs_libraries::lib_ctx(&state.kicad_info());+        let base = Path::new(file_path);+        let dir = if base.is_dir() { base.to_path_buf() } else { base.parent().unwrap_or(Path::new(".")).to_path_buf() };+        let mut fps = Vec::new();+        let ids: std::collections::BTreeSet<String> = library["symbols"].as_array().into_iter().flatten().filter_map(|s| s["footprint"].as_str().map(String::from)).filter(|f| f.contains(':')).collect();+        for id in ids {+            let (nick, name) = id.split_once(':').unwrap_or(("", &id));+            let sibling = dir.join(format!("{nick}.pretty")).join(format!("{name}.kicad_mod"));+            let path = if sibling.is_file() { Ok(sibling) } else { kicad_core::pcb_sync::resolve_footprint(&id, &dir, &ctx) };+            if let Some(info) = path.ok().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| kicad_core::fpedit::info(&t).ok()) {+                fps.push(fp_entry(&info, Some(&id)));+                referenced.insert(id);+            }+        }+        library["footprints"] = json!(fps);+    }+    let mut input = json!({"library": library, "args": {}});+    if let Some(n) = args.get("names").filter(|n| n.is_array()) {+        input["args"]["names"] = n.clone();+    }+    let mut r = adom_eda_checks::run("lint_library", &input);+    if !referenced.is_empty() {+        let keep: Vec<Value> = r["findings"].as_array().into_iter().flatten().filter(|x| !(x["kind"] == "footprint" && referenced.contains(x["name"].as_str().unwrap_or("")))).cloned().collect();+        let count = |sev: &str| keep.iter().filter(|x| x["severity"] == sev).count();+        r["errors"] = json!(count("error"));+        r["warnings"] = json!(count("warning"));+        r["infos"] = json!(count("info"));+        r["ok"] = json!(count("error") == 0 && r["unread"].as_array().map(|a| a.is_empty()).unwrap_or(true));+        r["checked"] = json!(r["checked"].as_u64().unwrap_or(0).saturating_sub(referenced.len() as u64));+        r["findings"] = json!(keep);+    }+    for k in ["findings", "checked", "errors", "warnings", "infos", "unread", "rules", "engine"] {+        v[k] = r[k].clone();+    }+    v["footprintsReferenced"] = json!(referenced);+    v["ok"] = json!(v["ok"] == true && r["ok"] == true);+    if let Some(o) = v["output"].as_str().map(String::from) {+        v["output"] = json!(format!("{o}; {} error(s), {} warning(s), {} info(s) by the standard's rules", r["errors"], r["warnings"], r["infos"]));+    }+    v+}++/// One footprint as the engine's lint reads it (`fpedit::info` in, mm in the footprint's own frame), named `name` or+/// its own name.+fn fp_entry(info: &Value, name: Option<&str>) -> Value {+        let pads: Vec<Value> = info["pads"].as_array().into_iter().flatten().map(|p| {+            let n = |a: &Value, i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0);+            // an unplated hole, or a pad with no copper (KiCad's paste-only apertures on an exposed pad), is not a pin's pad+            let copper = p["layers"].as_array().map(|l| l.iter().any(|x| x.as_str().is_some_and(|x| x.ends_with(".Cu")))).unwrap_or(true);+            let unplated = p["type"] == "np_thru_hole" || !copper;+            json!({"number": p["number"].as_str().unwrap_or(""), "x": n(&p["at"], 0), "y": n(&p["at"], 1), "w": n(&p["size"], 0), "h": n(&p["size"], 1),+                   "hole": p["drill"].as_array().and_then(|d| d.first()).and_then(Value::as_f64), "mechanical": unplated, "plated": !unplated})+        }).collect();+        let court = match info["courtyard"].is_object() {+            true => json!([info["courtyard"]["min"][0], info["courtyard"]["min"][1], info["courtyard"]["max"][0], info["courtyard"]["max"][1]]),+            false => Value::Null,+        };+        let silk: Vec<Value> = info["graphics"].as_object().into_iter().flatten().filter(|(k, _)| k.contains("SilkS")).map(|(k, n)| json!([k, n])).collect();+        let bodies: Vec<Value> = info["models"].as_array().into_iter().flatten().filter(|m| m["hidden"] != true).map(|m| m["path"].clone()).collect();+    json!({"name": name.map(|n| json!(n)).unwrap_or_else(|| info["name"].clone()), "pads": pads, "courtyard": court, "silk": silk, "bodies": bodies,+            "description": info["description"].as_str().unwrap_or("")})+}++/// The engine's library input from the lint's own read: each footprint's pads in its own frame, courtyard, silk,+/// 3D models and description; each symbol's pins (the first body style), Footprint field and part number. Files the+/// reader could not take are `unread`; files kicad-cli refuses are `tool_refuses_file` findings.+fn engine_library(v: &Value) -> Value {+    let data = &v["data"];+    let mut unread = Vec::new();+    let mut footprints = Vec::new();+    for m in data["footprints"].as_array().into_iter().flatten() {+        let path = m["path"].as_str().unwrap_or("");+        if let Some(e) = m["_error"].as_str() {+            unread.push(json!({"name": path, "error": e}));+            continue;+        }+        let Some(info) = std::fs::read_to_string(path).ok().and_then(|t| kicad_core::fpedit::info(&t).ok()) else {+            unread.push(json!({"name": path, "error": "the footprint reader could not take it"}));+            continue;+        };+        footprints.push(fp_entry(&info, None));+    }+    let mut symbols = Vec::new();+    for f in data["symbols"].as_array().into_iter().flatten() {+        let path = f["path"].as_str().unwrap_or("");+        if let Some(e) = f["_error"].as_str() {+            unread.push(json!({"name": path, "error": e}));+            continue;+        }+        let Ok(text) = std::fs::read_to_string(path) else { continue };+        for s in f["symbols"].as_array().into_iter().flatten() {+            let Some(name) = s["name"].as_str() else { continue };+            let Ok(info) = kicad_core::symedit::symbol_info(&text, name) else {+                unread.push(json!({"name": format!("{path}:{name}"), "error": "the symbol reader could not take it"}));+                continue;+            };+            if info["power"] == true {+                continue; // a power symbol is a net, not a part+            }+            let pins: Vec<Value> = info["pins"].as_array().into_iter().flatten().filter(|p| p["bodyStyle"].as_u64().unwrap_or(1) <= 1)+                .map(|p| json!({"number": p["number"].as_str().unwrap_or(""), "name": p["name"]})).collect();+            let fields = info["fields"].as_object().cloned().unwrap_or_default();+            let part = fields.iter().find(|(k, v)| {+                let k = k.to_lowercase().replace(['_', ' ', '-'], "");+                ["mpn", "manufacturerpartnumber", "mfrpartnumber", "mfrpn", "partnumber"].contains(&k.as_str()) && !v.as_str().unwrap_or("").trim().is_empty()+            }).map(|(_, v)| v.clone()).unwrap_or(json!(""));+            symbols.push(json!({"name": name, "pins": pins, "footprint": fields.get("Footprint").cloned().unwrap_or(json!("")), "partNumber": part}));+        }+    }+    let verdicts: Vec<Value> = match &v["kicadParser"] { Value::Array(a) => a.clone(), Value::Null => Vec::new(), o => vec![o.clone()] };+    let findings: Vec<Value> = verdicts.iter().filter(|x| x["available"] == true && x["ok"] == false).map(|x| {+        let file = x["path"].as_str().unwrap_or("").rsplit(['/', '\\']).next().unwrap_or("").to_string();+        json!({"name": file, "kind": "library", "rule": "tool_refuses_file", "severity": "error", "detail": format!("kicad-cli: {}", x["error"].as_str().unwrap_or("refused the file"))})+    }).collect();+    let mut lib = json!({"symbols": symbols, "unread": unread, "findings": findings});+    // footprints only when the library holds some: a symbol library alone leaves the footprint rules unevaluated+    if !data["footprints"].as_array().map(|a| a.is_empty()).unwrap_or(true) {+        lib["footprints"] = json!(footprints);+    }+    lib }  /// kicad_format_upgrade: installed, filePath, kind (given or detected), kicad-cli, run.
rust/crates/kicad-bridge/src/verbs_standard.rs+181
@@ -142,6 +142,16 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_select_net", "kicad_select_items", "kicad_render_board", "kicad_screenshot_all"],         pitfalls: &["KiCad frames the selection with its own margin; there is no margin to set.", "A rectangle is refused (not_supported): KiCad's API zooms to a selection, so name the parts in it."],     },+    Verb {+        name: "kicad_animate_view",+        summary: "Camera choreography on the open board, run inside the bridge so the timing is tight enough to narrate over: zooms on the PCB editor (KiCad's own actions through its API), and in the 3D Viewer orbits, rolls, views, pans and flips (its View-menu commands), all in the background.",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 240,+        input: "{\"preset\"?: \"punch_in|orbit|showcase3d|flip|finale\", \"steps\"?: [\"zoom_all\", {\"step\": \"orbit_right\", \"count\": 6}], \"pauseMs\"?: 600, \"lane\"?: \"pcb|3d\", \"filePath\"?: \"C:/.../x.kicad_pcb\"}",+        example: "kicad_animate_view {\"lane\":\"3d\",\"steps\":[\"top\",{\"step\":\"orbit_right\",\"count\":9},\"zoom_in\",\"flip\",\"zoom_all\"]}",+        hint: "Steps: zoom_all, zoom_in, zoom_out on either lane; on the 3D Viewer also top, bottom, front, back, left, right, orbit_left/right/up/down, roll_left/right, pan_left/right/up/down, flip, rotate90. view_3d and view_2d switch lanes inside one call (the 3D Viewer must be open: kicad_open_3d_viewer). skipped names steps KiCad has no command for (isometric, ortho). stepsRun counts the commands KiCad ran.",+        related: &["kicad_3d_view", "kicad_zoom_to", "kicad_open_3d_viewer"],+        pitfalls: &["The schematic lane is refused (not_supported): KiCad 10's Schematic Editor has no API.", "A big board takes a while per 3D redraw: keep pauseMs at 500 or more."],+    },     Verb {         name: "kicad_silk_state",         summary: "The silkscreen as it stands: every part's body box, its designator and value as placed (text, position, size, rotation, shown), and the free texts and graphics on both silkscreen layers.",@@ -170,6 +180,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_silk_state" => silk(state, args),         "kicad_set_layer_visibility" => set_layer_visibility(state, args),         "kicad_zoom_to" => zoom_to(state, args),+        "kicad_animate_view" => animate_view(state, args),         _ => return None,     }) }@@ -379,6 +390,153 @@ fn zoom_to(state: &mut State, args: &Value) -> Value {     } } +/// One animate_view step as KiCad does it: the PCB editor's action, or the 3D Viewer's kicad_3d_view action.+fn camera_step(step: &str, lane_3d: bool) -> Option<(&'static str, u32)> {+    if !lane_3d {+        return match step {+            "zoom_all" => Some(("common.Control.zoomFitScreen", 1)),+            "zoom_in" => Some(("common.Control.zoomInCenter", 1)),+            "zoom_out" => Some(("common.Control.zoomOutCenter", 1)),+            _ => None,+        };+    }+    Some(match step {+        "zoom_all" => ("fit", 1),+        "zoom_in" => ("zoom_in", 1),+        "zoom_out" => ("zoom_out", 1),+        "top" => ("view_top", 1),+        "bottom" => ("view_bottom", 1),+        "front" => ("view_front", 1),+        "back" => ("view_back", 1),+        "left" => ("view_left", 1),+        "right" => ("view_right", 1),+        "orbit_left" => ("rotate_z_ccw", 1),+        "orbit_right" => ("rotate_z_cw", 1),+        "orbit_up" => ("rotate_x_ccw", 1),+        "orbit_down" => ("rotate_x_cw", 1),+        "roll_left" => ("rotate_y_ccw", 1),+        "roll_right" => ("rotate_y_cw", 1),+        "pan_left" => ("pan_left", 1),+        "pan_right" => ("pan_right", 1),+        "pan_up" => ("pan_up", 1),+        "pan_down" => ("pan_down", 1),+        "flip" => ("flip", 1),+        // the 3D Viewer turns 10 degrees a command+        "rotate90" => ("rotate_z_cw", 9),+        _ => return None,+    })+}++/// A preset as steps.+fn camera_preset(name: &str) -> Option<Vec<(String, u32)>> {+    let v = |a: &[(&str, u32)]| a.iter().map(|(s, n)| (s.to_string(), *n)).collect();+    Some(match name {+        "punch_in" => v(&[("zoom_all", 1), ("zoom_in", 4)]),+        "orbit" => v(&[("view_3d", 1), ("orbit_right", 36)]),+        "showcase3d" => v(&[("view_3d", 1), ("zoom_all", 1), ("top", 1), ("orbit_down", 4), ("orbit_right", 18), ("zoom_in", 2), ("orbit_left", 9), ("zoom_out", 2), ("zoom_all", 1)]),+        "flip" => v(&[("view_3d", 1), ("flip", 1)]),+        "finale" => v(&[("view_3d", 1), ("top", 1), ("zoom_all", 1)]),+        _ => return None,+    })+}++/// `kicad_animate_view` (the standard's animate_view): camera steps run in the bridge with a set pause.+fn animate_view(state: &mut State, args: &Value) -> Value {+    if arg_str(args, "lane") == Some("sch") {+        return fail("not_supported", "KiCad 10's Schematic Editor has no API to move its view", "Animate the board (lane pcb) or the 3D Viewer (lane 3d); kicad_sch_zoom frames parts on a sheet as a picture.");+    }+    let mut steps: Vec<(String, u32)> = match arg_str(args, "preset") {+        Some(p) => match camera_preset(p) {+            Some(s) => s,+            None => return fail("bad_args", format!("preset {p:?} is not punch_in, orbit, showcase3d, flip or finale"), "Or give steps."),+        },+        None => Vec::new(),+    };+    for st in args.get("steps").and_then(Value::as_array).into_iter().flatten() {+        match st {+            Value::String(s) => steps.push((s.clone(), 1)),+            Value::Object(o) => steps.push((o.get("step").and_then(Value::as_str).unwrap_or("").to_string(), o.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 72) as u32)),+            _ => {}+        }+    }+    if steps.is_empty() {+        return fail("missing_arg", "steps or preset is required", "steps: [\"zoom_all\", {\"step\": \"orbit_right\", \"count\": 6}], or preset: orbit.");+    }+    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(600).clamp(50, 5000);+    let started = std::time::Instant::now();+    let mut lane_3d = arg_str(args, "lane") == Some("3d");+    let mut skipped = Vec::new();+    let mut run = 0u64;+    // consecutive steps on one lane go as one batch: one API connection, or one kicad_3d_view tour+    let mut batch: Vec<(String, u32)> = Vec::new();+    let mut flush = |state: &mut State, lane_3d: bool, batch: &mut Vec<(String, u32)>, run: &mut u64| -> Option<Value> {+        if batch.is_empty() {+            return None;+        }+        let n: u64 = batch.iter().map(|(_, c)| *c as u64).sum();+        let r = if lane_3d {+            // the viewer function itself: animate_view already holds the window lock kicad_3d_view's dispatch takes+            crate::verbs_windows::view_3d(&json!({"action": "tour", "steps": batch.iter().map(|(a, c)| json!([a, c])).collect::<Vec<_>>(), "pauseMs": pause}))+        } else {+            board_actions(state, args, batch, pause)+        };+        batch.clear();+        if r["success"] != true {+            return Some(r);+        }+        *run += n;+        None+    };+    for (step, count) in &steps {+        match step.as_str() {+            "view_3d" | "view_2d" => {+                if let Some(e) = flush(state, lane_3d, &mut batch, &mut run) {+                    return e;+                }+                lane_3d = step == "view_3d";+            }+            s => match camera_step(s, lane_3d) {+                Some((action, n)) => batch.push((action.to_string(), n * count)),+                None => skipped.push(json!({"step": s, "why": if lane_3d { "KiCad's 3D Viewer has no command for it" } else { "the PCB editor lane zooms only (zoom_all, zoom_in, zoom_out): view_3d first for the rest" }})),+            },+        }+    }+    if let Some(e) = flush(state, lane_3d, &mut batch, &mut run) {+        return e;+    }+    json!({"success": true, "stepsRun": run, "skipped": skipped, "elapsedSec": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0,+        "lane": if lane_3d { "3d" } else { "pcb" }, "windowEtiquette": "background",+        "output": format!("{run} camera command(s) in {:.1} s{}", started.elapsed().as_secs_f64(), if skipped.is_empty() { String::new() } else { format!("; {} step(s) skipped", skipped.len()) }),+        "_hint": "Everything ran in the background. kicad_screenshot_all (or a window recording) shows the view."})+}++#[cfg(feature = "ipc")]+fn board_actions(state: &mut State, args: &Value, steps: &[(String, u32)], pause: u64) -> Value {+    use kicad_core::{detect, ipc};+    let mut a = args.clone();+    if arg_str(args, "filePath").is_none() {+        match ipc::open_board_paths(std::time::Duration::from_secs(3)) {+            Ok(open) if open.len() == 1 => a["filePath"] = json!(open[0]),+            Ok(open) if open.len() > 1 => return fail("ambiguous_board", format!("{} boards are open: {}", open.len(), open.join(", ")), "Pass filePath: the board to animate."),+            _ => return fail("no_open_board", "no board is open in KiCad's PCB editor", "Open one with kicad_open_board, or pass filePath."),+        }+    }+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    match ipc::run_actions(&ctx, &a, steps, pause) {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn board_actions(_state: &mut State, _args: &Value, _steps: &[(String, u32)], _pause: u64) -> Value {+    fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Board zoom steps need the open PCB editor's API.")+}+ #[cfg(not(feature = "ipc"))] fn zoom_to(_state: &mut State, _args: &Value) -> Value {     fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Zooming needs the open PCB editor's API.")@@ -540,3 +698,26 @@ fn silk(state: &mut State, args: &Value) -> Value {         Err(e) => fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),     } }++#[cfg(test)]+mod camera_tests {+    #[test]+    fn camera_steps_map_to_kicads_commands() {+        assert_eq!(super::camera_step("zoom_all", false), Some(("common.Control.zoomFitScreen", 1)));+        assert_eq!(super::camera_step("orbit_right", false), None, "the board lane zooms only");+        assert_eq!(super::camera_step("orbit_right", true), Some(("rotate_z_cw", 1)));+        assert_eq!(super::camera_step("rotate90", true), Some(("rotate_z_cw", 9)));+        assert_eq!(super::camera_step("isometric", true), None);+        for p in ["punch_in", "orbit", "showcase3d", "flip", "finale"] {+            let mut lane_3d = false;+            for (s, _) in super::camera_preset(p).unwrap() {+                if s == "view_3d" {+                    lane_3d = true;+                    continue;+                }+                assert!(super::camera_step(&s, lane_3d).is_some(), "{p}: {s}");+            }+        }+        assert!(super::camera_preset("spin").is_none());+    }+}
rust/crates/kicad-core/src/board_edit.rs+35−5
@@ -1179,6 +1179,26 @@ pub fn updated_block(text: &str, fp: &Node, lib_text: &str, copper: usize) -> RR     Ok(b) } +/// A library id "Lib:Name" as (library nickname, footprint name).+fn lib_name(id: &str) -> (String, String) {+    match id.split_once(':') { Some((l, n)) => (l.to_string(), n.to_string()), None => (String::new(), id.to_string()) }+}++/// Which parts of a pad's signature differ (`pad_sig`): type, shape, position, rotation, size, drill, layers.+fn pad_changes(a: &str, b: &str) -> Vec<String> {+    fn parts(s: &str) -> Vec<(&'static str, String)> {+        let mut words = s.split_whitespace();+        let (t, sh) = (words.next().unwrap_or("").to_string(), words.next().unwrap_or("").to_string());+        let inner = |k: &str| s.find(&format!("{k}(")).map(|i| { let r = &s[i + k.len() + 1..]; r[..r.find(')').unwrap_or(r.len())].to_string() }).unwrap_or_default();+        let at = inner("at");+        let mut xyz = at.split(',');+        let (x, y, r) = (xyz.next().unwrap_or("").to_string(), xyz.next().unwrap_or("").to_string(), xyz.next().unwrap_or("").to_string());+        vec![("type", t), ("shape", sh), ("position", format!("{x},{y}")), ("rotation", r), ("size", inner("size")), ("drill", inner("drill")), ("layers", inner("layers"))]+    }+    let (pa, pb) = (parts(a), parts(b));+    pa.iter().zip(&pb).filter(|(x, y)| x.1 != y.1).map(|(x, _)| x.0.to_string()).collect()+}+ /// The differences between a placed footprint and its library version, in words. pub fn footprint_diff(old: &Node, new: &Node) -> Value {     let pads = |f: &Node| -> Vec<(String, String)> { f.kids_tagged("pad").map(|p| (p.atom(1).unwrap_or("").to_string(), pad_sig(p))).collect() };@@ -1192,7 +1212,9 @@ pub fn footprint_diff(old: &Node, new: &Node) -> Value {         let a: Vec<&String> = po.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();         let b: Vec<&String> = pn.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();         if a != b {-            changed_pads.push(json!({"pad": k, "board": a, "library": b}));+            // the standard's changes[] (update_footprints_from_library, #27) beside both signatures+            let changes: Vec<String> = match (a.first(), b.first()) { (Some(x), Some(y)) if a.len() == b.len() => pad_changes(x, y), _ => vec!["count".into()] };+            changed_pads.push(json!({"pad": k, "changes": changes, "board": a, "library": b}));         }     }     let gfx_changed = graphic_sig(old) != graphic_sig(new);@@ -1236,27 +1258,27 @@ pub fn update_from_library(text: &str, args: &Value, resolve: &mut dyn FnMut(&st             Ok(t) => t,             Err(why) => {                 unresolved += 1;-                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": "library_not_found", "why": why}));+                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": "library_not_found", "why": why}));                 continue;             }         };         let block = match updated_block(text, f, &lib, copper) {             Ok(b) => b,             Err(e) => {-                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": "unusable", "why": e.message}));+                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": "unusable", "why": e.message}));                 continue;             }         };         let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;         let diff = footprint_diff(f, &nf);         let identical = diff["identical"] == json!(true);-        plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": if identical { "up_to_date" } else { "differs" }, "diff": diff}));+        plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": if identical { "up_to_date" } else { "differs" }, "diff": diff}));         if !identical {             rows.push((*f, block));         }     }     let differs = rows.len();-    let report = json!({"footprints": plan, "checked": fps.len(), "differ": differs, "unresolved": unresolved});+    let report = json!({"footprints": plan, "checked": fps.len(), "differ": differs, "differs": differs, "unresolved": unresolved});     if rows.is_empty() {         return Ok(Change { candidate: text.to_string(), report, label: "Adom update footprints".into(), ..Default::default() });     }@@ -1526,4 +1548,12 @@ mod tests {         assert_eq!(mounting_hole_lib_id(3.2, true).as_deref(), Some("MountingHole:MountingHole_3.2mm_M3_Pad"));         assert_eq!(mounting_hole_lib_id(3.0, false), None);     }++    #[test]+    fn a_pads_changes_are_named_the_standards_way() {+        let a = "smd roundrect at(1.0000,0.0000,0.0000) size(0.6000,0.8000) drill() layers(F.Cu F.Paste F.Mask)";+        let b = "smd roundrect at(1.0000,0.0000,90.0000) size(2.0000,0.9000) drill() layers(F.Cu F.Paste F.Mask)";+        assert_eq!(super::pad_changes(a, b), vec!["rotation".to_string(), "size".to_string()]);+        assert_eq!(super::lib_name("Package_SO:SOIC-8"), ("Package_SO".to_string(), "SOIC-8".to_string()));+    } }
rust/crates/kicad-core/src/ipc.rs+22
@@ -694,6 +694,28 @@ pub fn zoom_to(ctx: &Ctx, args: &Value) -> RResult<Value> {     })) } +/// KiCad actions run on the live PCB editor over one API connection, each `count` times with `pause_ms` between+/// (camera steps for animate_view: run in the bridge, so the timing is tight enough to narrate over). Stops at the+/// first action KiCad does not run.+pub fn run_actions(ctx: &Ctx, args: &Value, steps: &[(String, u32)], pause_ms: u64) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let mut ran = Vec::new();+    for (action, count) in steps {+        for _ in 0..(*count).max(1) {+            let status = s.client.run_action(action).map_err(map_err)?;+            let st = format!("{status:?}");+            let ok = st.to_lowercase().contains("ok");+            ran.push(json!({"action": action, "status": st}));+            if !ok {+                return Ok(json!({"success": false, "errorCode": "action_failed", "error": format!("KiCad did not run {action}: {st}"), "ran": ran,+                    "_hint": "The PCB editor may be busy with an interactive tool (press Escape in it), or not the editor serving the API."}));+            }+            std::thread::sleep(std::time::Duration::from_millis(pause_ms));+        }+    }+    Ok(json!({"success": true, "mutated": false, "source": "live-editor", "ran": ran}))+}+ /// `kicad_select_clear`: clear the live editor's selection. pub fn select_clear(ctx: &Ctx, args: &Value) -> RResult<Value> {     let s = connect(ctx, args)?;
rust/crates/kicad-core/src/lint.rs+7−2
@@ -775,7 +775,8 @@ pub fn verdict_file(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> V     let r = cli::run(cli, &["sym", "export", "svg", "-o", &out_s, &lib_s], timeout);     let _ = std::fs::remove_dir_all(&out_dir);     if r.timed_out {-        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+        // no answer is not a refusal: KiCad's verdict on this file is unknown+        return json!({"available": true, "ok": null, "timedOut": true, "symbols": [], "units": 0,                       "error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});     }     if r.exit_code == -1 {@@ -1037,7 +1038,7 @@ pub fn lint_library(path: &Path, cli: Option<&Path>, verdict_timeout: Duration)         v["path"] = json!(f.to_string_lossy().to_string());         let available = v.get("available").and_then(Value::as_bool).unwrap_or(false);         let ok = v.get("ok").and_then(Value::as_bool).unwrap_or(false);-        if available && !ok {+        if available && !ok && v.get("timedOut") != Some(&json!(true)) {             kicad_refused.push(v.clone());         }         kicad_verdicts.push(v);@@ -1057,6 +1058,10 @@ pub fn lint_library(path: &Path, cli: Option<&Path>, verdict_timeout: Duration)     if !kicad_refused.is_empty() {         output.push_str(&format!(", KiCad refuses {} file(s)", kicad_refused.len()));     }+    let not_asked = kicad_verdicts.iter().filter(|v| v.get("timedOut") == Some(&json!(true))).count();+    if not_asked > 0 {+        output.push_str(&format!(", KiCad did not answer in time for {not_asked} file(s) (its verdict is unknown, not a refusal)"));+    }     json!({         "success": true,         "ok": ok,
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.20.0"+version = "0.21.1" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+1
@@ -29,6 +29,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs |+| `lint_library` | library | is a symbol or footprint library fit to ship (standard #28): the shared rules with a fixed severity each (`no_pads`, `zero_size_pad`, `duplicate_pad_number`, `no_courtyard`, `origin_off_part`, `pin_without_pad`, `pad_without_pin`, `no_part_number`, ...; `rules` in the reply lists them), checked from the footprints and symbols the bridge sends; a key left out is not checked, a key sent empty is a finding; the bridge's own findings (`tool_refuses_file`) pass through | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.23.0-- Standard commit: c3c265238d4353b7e3f7650f33c07ef699c28ead-- Crate version: 0.20.0+- Standard version: 0.24.2+- Standard commit: d104b57e76bd000fd5271711ee15c85e01deeaf6+- Crate version: 0.21.1 - Copied: 2026-10-29, by kicad-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/lib.rs+4−2
@@ -4,7 +4,7 @@ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, library?, args?}` (see `input`). use serde_json::Value;  pub mod assembly;@@ -21,6 +21,7 @@ pub mod input; pub mod landpattern; pub mod lengths; pub mod lengthtune;+pub mod liblint; pub mod pairroute; pub mod panel; pub mod report;@@ -39,7 +40,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION");  /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -65,6 +66,7 @@ fn answer(check: &str, input: &Value) -> Value {         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "length_tune" => lengthtune::length_tune(input),+        "lint_library" => liblint::lint_library(input),         "net_lengths" => lengths::net_lengths(input),         "panelize" => panel::panelize(input),         "pin_pad_check" => schreview::pin_pad_check(input),
rust/vendor/adom-eda-checks/src/liblint.rsadded+239
@@ -0,0 +1,239 @@+//! Library lint (EDA Bridge Standard #28): is a symbol or footprint library fit to ship? The content rules every bridge+//! can share, from the footprints and symbols it already reads; a bridge adds its tool's own (KiCad's+//! `tool_refuses_file`) to the same findings. Severity belongs to the rule, not the bridge. A rule is only evaluated+//! when the bridge sent what it needs: a key left out means "not read" and emits nothing, a key sent empty is a finding.+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};++/// The shared vocabulary: (kind, rule, severity).+pub const RULES: &[(&str, &str, &str)] = &[+    ("footprint", "no_pads", "error"),+    ("footprint", "zero_size_pad", "error"),+    ("footprint", "unnumbered_pad", "warning"),+    ("footprint", "duplicate_pad_number", "warning"),+    ("footprint", "no_courtyard", "warning"),+    ("footprint", "origin_off_part", "warning"),+    ("footprint", "no_silkscreen", "info"),+    ("footprint", "no_3d_body", "info"),+    ("footprint", "no_description", "info"),+    ("symbol", "no_pins", "error"),+    ("symbol", "footprint_not_found", "error"),+    ("symbol", "pin_without_pad", "error"),+    ("symbol", "unnumbered_pin", "warning"),+    ("symbol", "duplicate_pin_number", "warning"),+    ("symbol", "no_footprint", "warning"),+    ("symbol", "pad_without_pin", "warning"),+    ("symbol", "no_part_number", "info"),+    ("library", "tool_refuses_file", "error"),+];++/// How far past the pads and courtyard the origin may sit before it counts as off the part (mm).+pub const ORIGIN_MARGIN_MM: f64 = 1.0;++fn severity(rule: &str) -> &'static str { RULES.iter().find(|r| r.1 == rule).map(|r| r.2).unwrap_or("warning") }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn text(v: &Value) -> String { match v { Value::String(s) => s.trim().to_string(), Value::Number(n) => n.to_string(), _ => String::new() } }+/// Sent but empty: null, "", [] or {}.+fn empty(v: &Value) -> bool { match v { Value::Null => true, Value::String(s) => s.trim().is_empty(), Value::Array(a) => a.is_empty(), Value::Object(o) => o.is_empty(), _ => false } }+/// A pad no pin connects to: a mechanical hole, or unplated.+fn mechanical(p: &Value) -> bool { p["mechanical"] == true || p["plated"] == false }+/// "Lib:Name" and "Name" name the same footprint.+fn base(name: &str) -> &str { name.rsplit(':').next().unwrap_or(name) }+/// "pin 3 has" or "pins 2, 3 have".+fn list(word: &str, items: &[&String]) -> String {+    let names = items.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");+    if items.len() == 1 { format!("{word} {names} has") } else { format!("{word}s {names} have") }+}++/// A courtyard's extent: [x0, y0, x1, y1], {x0, y0, x1, y1}, or a list of [x, y] points.+fn extent(c: &Value) -> Option<(f64, f64, f64, f64)> {+    if let Some(a) = c.as_array() {+        if a.len() == 4 && a.iter().all(|v| v.is_number()) { return Some((f(&a[0]).min(f(&a[2])), f(&a[1]).min(f(&a[3])), f(&a[0]).max(f(&a[2])), f(&a[1]).max(f(&a[3])))); }+        let pts: Vec<(f64, f64)> = a.iter().filter_map(|p| p.as_array().filter(|q| q.len() >= 2).map(|q| (f(&q[0]), f(&q[1])))).collect();+        if pts.is_empty() { return None; }+        return Some(pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))));+    }+    if c["x0"].is_number() { return Some((f(&c["x0"]).min(f(&c["x1"])), f(&c["y0"]).min(f(&c["y1"])), f(&c["x0"]).max(f(&c["x1"])), f(&c["y0"]).max(f(&c["y1"])))); }+    None+}++/// A pad's copper box (the hole's, if larger), in the footprint's frame.+fn pad_box(p: &Value) -> (f64, f64, f64, f64) {+    let (x, y) = (f(&p["x"]), f(&p["y"]));+    let (w, h) = (f(&p["w"]).max(f(&p["hole"])) / 2.0, f(&p["h"]).max(f(&p["hole"])) / 2.0);+    (x - w, y - h, x + w, y + h)+}++/// How many separate lands the boxes make: boxes that touch or overlap (within 0.01 mm) join.+fn lands(boxes: &[(f64, f64, f64, f64)]) -> usize {+    let mut group: Vec<usize> = (0..boxes.len()).collect();+    fn root(g: &mut Vec<usize>, i: usize) -> usize { let mut i = i; while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }+    let touch = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64)| a.0 <= b.2 + 0.01 && b.0 <= a.2 + 0.01 && a.1 <= b.3 + 0.01 && b.1 <= a.3 + 0.01;+    for i in 0..boxes.len() { for j in i + 1..boxes.len() { if touch(&boxes[i], &boxes[j]) { let (a, b) = (root(&mut group, i), root(&mut group, j)); group[a] = b; } } }+    (0..boxes.len()).filter(|&i| root(&mut group, i) == i).count()+}++fn finding(name: &str, kind: &str, rule: &str, detail: String) -> Value {+    json!({"name": name, "kind": kind, "rule": rule, "severity": severity(rule), "detail": detail})+}++/// The findings for one footprint (pads in its own frame, mm, origin at 0,0).+pub fn lint_footprint(fp: &Value) -> Vec<Value> {+    let name = text(&fp["name"]);+    let mut out = Vec::new();+    let pads = fp["pads"].as_array().cloned().unwrap_or_default();+    if pads.is_empty() { out.push(finding(&name, "footprint", "no_pads", "the footprint has no pads".into())); }+    let mut seen: BTreeMap<String, Vec<(f64, f64, f64, f64)>> = BTreeMap::new();+    for p in &pads {+        let (w, h, hole) = (f(&p["w"]), f(&p["h"]), f(&p["hole"]));+        if (w <= 0.0 || h <= 0.0) && hole <= 0.0 { out.push(finding(&name, "footprint", "zero_size_pad", format!("pad {} is {w} x {h} mm", text(&p["number"])))); }+        let n = text(&p["number"]);+        if n.is_empty() { if !mechanical(p) { out.push(finding(&name, "footprint", "unnumbered_pad", format!("a pad at ({}, {}) has no number", f(&p["x"]), f(&p["y"])))); } }+        else if !mechanical(p) { seen.entry(n).or_default().push(pad_box(p)); }+    }+    // pads sharing a number that touch or overlap are one land (a split exposed pad, thermal vias in it): a duplicate+    // is a number on lands apart from each other+    for (n, boxes) in &seen {+        let lands = lands(boxes);+        if lands > 1 { out.push(finding(&name, "footprint", "duplicate_pad_number", format!("pad number {n} is on {lands} separate lands ({} pads)", boxes.len()))); }+    }+    let court = fp.get("courtyard");+    if court.map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_courtyard", "no courtyard".into())); }+    // the origin should sit on the part: inside the pads and courtyard, grown by the margin+    let mut b: Option<(f64, f64, f64, f64)> = None;+    let mut grow = |e: (f64, f64, f64, f64)| b = Some(b.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));+    for p in &pads { let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["w"]).max(f(&p["hole"])) / 2.0, f(&p["h"]).max(f(&p["hole"])) / 2.0); grow((x - w, y - h, x + w, y + h)); }+    if let Some(e) = court.and_then(extent) { grow(e); }+    if let Some((x0, y0, x1, y1)) = b {+        let m = ORIGIN_MARGIN_MM;+        if 0.0 < x0 - m || 0.0 > x1 + m || 0.0 < y0 - m || 0.0 > y1 + m {+            let (dx, dy) = ((x0 - m).max(0.0) + (0.0f64).max(-(x1 + m)), (y0 - m).max(0.0) + (0.0f64).max(-(y1 + m)));+            out.push(finding(&name, "footprint", "origin_off_part", format!("the origin is {:.3} mm outside the pads and courtyard (part spans x {x0:.3}..{x1:.3}, y {y0:.3}..{y1:.3})", (dx * dx + dy * dy).sqrt())));+        }+    }+    if fp.get("silk").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_silkscreen", "no silkscreen".into())); }+    if fp.get("bodies").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_3d_body", "no 3D body".into())); }+    if fp.get("description").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_description", "no description".into())); }+    out+}++/// The findings for one symbol, against the footprints the bridge sent (None: footprints not read, so the+/// footprint rules that need them are not evaluated).+pub fn lint_symbol(sym: &Value, footprints: Option<&BTreeMap<String, Value>>) -> Vec<Value> {+    let name = text(&sym["name"]);+    let mut out = Vec::new();+    let pins = sym["pins"].as_array().cloned().unwrap_or_default();+    if pins.is_empty() { out.push(finding(&name, "symbol", "no_pins", "the symbol has no pins".into())); }+    let mut numbers: BTreeMap<String, usize> = BTreeMap::new();+    for p in &pins {+        let n = text(&p["number"]);+        if n.is_empty() { out.push(finding(&name, "symbol", "unnumbered_pin", format!("pin {} has no number", text(&p["name"])))); }+        else { *numbers.entry(n).or_default() += 1; }+    }+    for (n, k) in &numbers { if *k > 1 { out.push(finding(&name, "symbol", "duplicate_pin_number", format!("{k} pins are numbered {n}"))); } }+    let fps: Vec<String> = match sym.get("footprint") {+        Some(Value::Array(a)) => a.iter().map(text).filter(|s| !s.is_empty()).collect(),+        Some(v) => Some(text(v)).filter(|s| !s.is_empty()).into_iter().collect(),+        None => Vec::new(),+    };+    if sym.get("footprint").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_footprint", "no footprint is linked".into())); }+    if let Some(lib) = footprints {+        for fpn in &fps {+            let Some(fp) = lib.get(fpn).or_else(|| lib.get(base(fpn))) else {+                out.push(finding(&name, "symbol", "footprint_not_found", format!("footprint {fpn} is not in the footprint libraries checked")));+                continue;+            };+            let pads: BTreeSet<String> = fp["pads"].as_array().map(|a| a.iter().filter(|p| !mechanical(p)).map(|p| text(&p["number"])).filter(|n| !n.is_empty()).collect()).unwrap_or_default();+            let missing: Vec<&String> = numbers.keys().filter(|n| !pads.contains(*n)).collect();+            if !missing.is_empty() { out.push(finding(&name, "symbol", "pin_without_pad", format!("{} no pad on {fpn}", list("pin", &missing)))); }+            let spare: Vec<&String> = pads.iter().filter(|n| !numbers.contains_key(*n)).collect();+            if !spare.is_empty() { out.push(finding(&name, "symbol", "pad_without_pin", format!("{} no pin on {fpn}", list("pad", &spare)))); }+        }+    }+    if sym.get("partNumber").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_part_number", "no manufacturer part number".into())); }+    out+}++/// lint_library: input {library: {footprints?: [{name, pads: [{number, x, y, w, h, hole?, mechanical?, plated?}],+/// courtyard?, silk?, bodies?, description?}], symbols?: [{name, pins: [{number, name?}], footprint?, partNumber?}],+/// unread?: [{name, error}], findings?: [the bridge's own]}, args: {names?}}. Symbols are checked against the footprints+/// sent (the library's own, or those of footprintLibraries the bridge resolved).+pub fn lint_library(input: &Value) -> Value {+    let lib = &input["library"];+    if !lib.is_object() { return crate::input::refuse("bad_args", "library is required: {footprints?, symbols?, unread?, findings?}"); }+    let names: Option<BTreeSet<String>> = input["args"]["names"].as_array().map(|a| a.iter().map(text).collect());+    let wanted = |v: &Value| names.as_ref().map(|n| n.contains(&text(&v["name"]))).unwrap_or(true);+    let fps = lib["footprints"].as_array().cloned().unwrap_or_default();+    let syms = lib["symbols"].as_array().cloned().unwrap_or_default();+    let by_name: Option<BTreeMap<String, Value>> = lib.get("footprints").filter(|v| v.is_array()).map(|_| fps.iter().map(|fp| (text(&fp["name"]), fp.clone())).flat_map(|(n, fp)| [(base(&n).to_string(), fp.clone()), (n, fp)]).collect());+    let mut findings: Vec<Value> = lib["findings"].as_array().cloned().unwrap_or_default();+    let mut checked = 0;+    for fp in fps.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_footprint(fp)); }+    for s in syms.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_symbol(s, by_name.as_ref())); }+    let count = |sev: &str| findings.iter().filter(|x| x["severity"] == sev).count();+    let (errors, warnings, infos) = (count("error"), count("warning"), count("info"));+    let unread = lib["unread"].as_array().cloned().unwrap_or_default();+    json!({"success": true, "ok": errors == 0 && unread.is_empty(), "checked": checked, "errors": errors, "warnings": warnings, "infos": infos,+        "findings": findings, "unread": unread,+        "rules": RULES.iter().map(|r| json!({"kind": r.0, "rule": r.1, "severity": r.2})).collect::<Vec<_>>(),+        "_hint": "Errors stop a part working; warnings are usually mistakes; infos are gaps. A rule is only checked when the bridge read what it needs, so a missing finding is not a pass for a rule the bridge could not read."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn pad(n: &str, x: f64, y: f64) -> Value { json!({"number": n, "x": x, "y": y, "w": 0.6, "h": 1.5}) }++    #[test]+    fn a_clean_footprint_and_its_symbol_pass() {+        let r = lint_library(&json!({"library": {+            "footprints": [{"name": "SOT23", "pads": [pad("1", -0.95, -1.1), pad("2", 0.95, -1.1), pad("3", 0.0, 1.1)], "courtyard": [-1.7, -2.0, 1.7, 2.0], "silk": [[0, 0]], "bodies": [{"h": 1.1}], "description": "SOT-23"}],+            "symbols": [{"name": "BJT", "pins": [{"number": "1"}, {"number": "2"}, {"number": "3"}], "footprint": "Lib:SOT23", "partNumber": "MMBT3904"}]}}));+        assert_eq!(r["ok"], true, "{r}");+        assert_eq!(r["checked"], 2);+        assert_eq!(r["findings"], json!([]));+    }++    #[test]+    fn the_shared_rules_fire_with_their_own_severity() {+        // the xcopy bug of altium-bridge#29: a copy 1270 mm off its origin+        let far = json!({"name": "BAT_FP_X", "pads": [pad("1", 1270.0, 0.0), pad("1", 1272.0, 0.0), json!({"number": "", "x": 1271.0, "y": 0.0, "w": 0.0, "h": 0.0})], "courtyard": null, "silk": [], "description": ""});+        let r = lint_library(&json!({"library": {"footprints": [far],+            "symbols": [{"name": "BAT", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "BAT_FP_X", "partNumber": ""}, {"name": "LOST", "pins": [{"number": "1"}], "footprint": "NOPE"}, {"name": "BARE", "pins": [], "footprint": ""}],+            "findings": [{"name": "X", "kind": "library", "rule": "tool_refuses_file", "severity": "error", "detail": "kicad-cli: bad token"}]}}));+        let rules: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|x| x["rule"].as_str().unwrap()).collect();+        for want in ["tool_refuses_file", "zero_size_pad", "unnumbered_pad", "duplicate_pad_number", "no_courtyard", "origin_off_part", "no_silkscreen", "no_description", "pin_without_pad", "no_part_number", "footprint_not_found", "no_pins", "no_footprint"] {+            assert!(rules.contains(&want), "{want} missing from {rules:?}");+        }+        // bodies was not sent, so no_3d_body is not evaluated+        assert!(!rules.contains(&"no_3d_body"));+        assert_eq!(r["ok"], false);+        assert!(r["findings"].as_array().unwrap().iter().all(|x| x["severity"] == severity(x["rule"].as_str().unwrap())));+        let off = r["findings"].as_array().unwrap().iter().find(|x| x["rule"] == "origin_off_part").unwrap();+        assert!(off["detail"].as_str().unwrap().starts_with("the origin is 1268.700 mm"), "{off}");+    }++    #[test]+    fn a_split_exposed_pad_is_one_land() {+        // KiCad's Package_SO: an exposed pad 17 as four tiles and two thermal vias, plus two pins both numbered 1+        let ep: Vec<Value> = [(-0.6, -0.6), (0.6, -0.6), (-0.6, 0.6), (0.6, 0.6)].iter().map(|&(x, y)| json!({"number": "17", "x": x, "y": y, "w": 1.2, "h": 1.2})).chain([json!({"number": "17", "x": 0.0, "y": 0.0, "w": 0.6, "h": 0.6, "hole": 0.3}), json!({"number": "17", "x": 0.3, "y": 0.0, "w": 0.0, "h": 0.0, "mechanical": true})]).collect();+        let mut pads = ep.clone();+        pads.extend([pad("1", -2.7, 1.9), pad("1", 2.7, 1.9)]);+        let r = lint_library(&json!({"library": {"footprints": [{"name": "HTSSOP", "pads": pads}]}}));+        let d: Vec<&str> = r["findings"].as_array().unwrap().iter().filter(|x| x["rule"] == "duplicate_pad_number").map(|x| x["detail"].as_str().unwrap()).collect();+        assert_eq!(d, vec!["pad number 1 is on 2 separate lands (2 pads)"], "{r}");+    }++    #[test]+    fn a_spare_pad_warns_and_a_mechanical_one_does_not() {+        let fp = json!({"name": "USB", "pads": [pad("1", 0.0, 0.0), pad("2", 0.65, 0.0), pad("S1", 3.0, 0.0), json!({"number": "", "x": -2.0, "y": 1.0, "w": 0.0, "h": 0.0, "hole": 0.65, "plated": false})]});+        let r = lint_library(&json!({"library": {"footprints": [fp], "symbols": [{"name": "USB", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "USB"}]}}));+        let f: Vec<(&str, &str)> = r["findings"].as_array().unwrap().iter().map(|x| (x["rule"].as_str().unwrap(), x["detail"].as_str().unwrap())).collect();+        assert_eq!(f, vec![("pad_without_pin", "pad S1 has no pin on USB")], "{r}");+        assert_eq!(r["ok"], true, "a warning does not fail the library");+        // a name filter, and unread items fail ok+        let r = lint_library(&json!({"library": {"footprints": [{"name": "A", "pads": []}, {"name": "B", "pads": [pad("1", 0.0, 0.0)]}], "unread": [{"name": "C", "error": "locked"}]}, "args": {"names": ["B"]}}));+        assert_eq!((r["checked"].clone(), r["errors"].clone(), r["ok"].clone()), (json!(1), json!(0), json!(false)));+    }+}
rust/vendor/adom-eda-checks/src/main.rs+1−1
@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> {     while let Some(flag) = it.next() {         let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;         match flag.as_str() {-            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" => {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" | "--library" => {                 let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };                 b[key] = read(val)?;             }
skills/kicad-bridge-test/run_verb_tests.py+10−2
@@ -391,7 +391,7 @@ def matrix(w: dict) -> dict:             "routing_state", "board_pads", "routing_validate", "route_net", "remove_route",             "add_zone", "zone_state", "remove_zone", "apply_zone_plan", "select_net", "select_clear",             "text_bounds", "silk_text_batch", "silk_graphics_batch", "silk_fields_batch",-            "menu_items", "3d_view", "refresh_board_views", "set_layer_visibility", "zoom_to")},+            "menu_items", "3d_view", "refresh_board_views", "set_layer_visibility", "zoom_to", "animate_view")},         # ---- phase 6.6: offline copper editing on a second copy that no editor has open         **{verb: dict(phase=6.6, live=True) for verb in ("add_track", "add_via", "route")},         "install_library_bundle": dict(phase=4, args={"__special": "install_bundle"}, timeout=200,@@ -1106,6 +1106,11 @@ def main():             record("menu_items", r, ok(r) and len(r.get("items") or []) > 0)             r = step("3d_view", {"action": "rotate_z_cw", "count": 1, "pauseMs": 500, "reason": WHY}, chain=False)             record("3d_view", r, ok(r))+            # 1.0.89: the standard's animate_view: the board's zooms through KiCad's API, then the 3D Viewer's orbit, in one+            # call with the timing kept in the bridge; isometric has no KiCad command and is reported, not faked+            r = step("animate_view", {"steps": ["zoom_all", {"step": "zoom_in", "count": 2}, "view_3d", {"step": "orbit_right", "count": 3}, "isometric", "zoom_all"],+                                      "pauseMs": 300, "reason": WHY}, chain=False)+            record("animate_view", r, ok(r) and r.get("stepsRun") == 7 and [x.get("step") for x in r.get("skipped") or []] == ["isometric"])             r = step("refresh_board_views", {"pcbHwnd": pcb_hwnd, "reason": WHY}, chain=False)             record("refresh_board_views", r, ok(r) and r.get("refreshRequested") is not False)             ab(a.target, "kicad_close", {"force": True}, timeout=120)   # discard: the scratch copy is never saved@@ -1590,10 +1595,13 @@ def main():         bga = C("footprint_from_land_pattern", {"name": "BGA", "family": "bga", "rows": 8, "cols": 8, "pitch": 0.8, "ballPad": 0.4, "body": {"w": 7, "l": 7}, "dryRun": True})         r0 = C("footprint_from_land_pattern", dict(lp, dryRun=True))         r = C("footprint_from_land_pattern", lp)+        # 1.0.89 (#28): the library it wrote passes the standard's lint rules (engine lint_library) with no errors+        ll = C("lint_library", {"libraryPath": w["out"] + "/LandTest.pretty"}, 200)         own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0] == ["1", -2.7, 1.905, 1.55, 0.6]             and str(r0.get("engine") or "").startswith("adom-eda-checks ") and ok(r) and (r.get("body3d") or {}).get("success") is True             and [p[2] for p in ((sot.get("summary") or {}).get("padTable") or [])][3:] == [-0.95, 0.95]-            and (bga.get("summary") or {}).get("courtyardMm") == [-3.75, -3.75, 3.75, 3.75],+            and (bga.get("summary") or {}).get("courtyardMm") == [-3.75, -3.75, 3.75, 3.75]+            and ok(ll) and ll.get("errors") == 0 and len(ll.get("rules") or []) >= 18 and str(ll.get("engine") or "").startswith("adom-eda-checks "),             "no pad table from the engine (1.0.85: the standard's engine lays it out), or the footprint or its body was not written")         # 1.0.69: a sheet read from its file, boards built from footprints, a mouse-bite panel         r = C("read_sheet_file", {"path": w["sch"], "params": True})