app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #318: kicad-lane: 1.0.71, library_views and set_layer_visibility
12 files changed
+496−9
bridge.json+5−2rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/groups.rs+1rust/crates/kicad-bridge/src/main.rs+1rust/crates/kicad-bridge/src/verbs_library_views.rs+222rust/crates/kicad-bridge/src/verbs_standard.rs+57rust/crates/kicad-core/src/ipc.rs+21rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/library_views.rs+151skills/kicad-board-checks/SKILL.md+14−1skills/kicad-bridge-test/run_verb_tests.py+19−2bridge.json+5−2@@ -301,7 +301,9 @@ "kicad_length_tune", "kicad_silkscreen_prep", "kicad_component_classes",- "kicad_build_schematic"+ "kicad_build_schematic",+ "kicad_set_layer_visibility",+ "kicad_library_views" ], "statusVerb": "kicad_status", "timeouts": {@@ -321,7 +323,8 @@ "export_molecule": 420, "freerouting": 900, "autoroute": 900,- "prewarm": 120+ "prewarm": 120,+ "library_views": 300 } }, "dependencies": {
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.70"+version = "1.0.71" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.70"+version = "1.0.71" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.70"+version = "1.0.71" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.70"+version = "1.0.71" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+1@@ -44,6 +44,7 @@ pub static GROUPS: &[Group] = &[ Group { name: "report", verbs: || crate::verbs_report::VERBS, dispatch: crate::verbs_report::dispatch }, Group { name: "order", verbs: || crate::verbs_order::VERBS, dispatch: crate::verbs_order::dispatch }, Group { name: "land_pattern", verbs: || crate::verbs_land_pattern::VERBS, dispatch: crate::verbs_land_pattern::dispatch },+ Group { name: "library_views", verbs: || crate::verbs_library_views::VERBS, dispatch: crate::verbs_library_views::dispatch }, Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch }, ];
rust/crates/kicad-bridge/src/main.rs+1@@ -41,6 +41,7 @@ mod verbs_checks_native; mod verbs_report; mod verbs_order; mod verbs_land_pattern;+mod verbs_library_views; mod verbs_build; mod verbs_windows;
rust/crates/kicad-bridge/src/verbs_library_views.rsadded+222@@ -0,0 +1,222 @@+//! Verb group "library_views": `kicad_library_views`, the EDA Bridge Standard's library_views.+//! Headless, through kicad-cli: the symbol (`sym export svg`) and the footprint (`fp export svg`+//! with pad numbers) as PNGs, the 3D body seated on its pads (`pcb render` of a one-part board,+//! kicad_core::library_views::view_board), and the device view, which KiCad does not have: the+//! symbol's pins mapped to the footprint's pads, as an SVG table. Nothing is opened.++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::{cli, fpedit, library_views, model_check, sch_render, symedit};++pub static VERBS: &[Verb] = &[Verb {+ name: "kicad_library_views",+ summary: "Pictures of library parts for review, each as an image file: the schematic symbol, the footprint (pads with their numbers), the 3D body seated on the footprint, and the device view (every symbol pin mapped to a pad).",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\" | \"C:/.../Mine.pretty\", \"parts\": [\"name\"] | \"A;B\", \"views\"?: [\"symbol\", \"footprint\", \"3d\", \"device\"], \"outputDir\"?: \"C:/...\", \"symbolLibraryPath\"?: \"C:/.../Mine.kicad_sym\", \"footprintLibraryPath\"?: \"C:/.../Mine.pretty\", \"width\"?: 800}",+ example: "kicad_library_views {\"libraryPath\":\"C:/Users/john/kicad/Power.kicad_sym\",\"parts\":[\"TPS54202\"]}",+ hint: "From a symbol library the footprint is the symbol's Footprint field (KiCad's fp-lib-tables, or footprintLibraryPath by name); from a .pretty folder the symbol is the same name in symbolLibraryPath. captured[] has one entry per part and view: {part, view, path, ok, error?}. Read the PNGs: pin names on the symbol, pad numbers on the footprint, the body over its pads. The device entry's pinsWithoutPad / padsWithoutPin should both be empty.",+ related: &["kicad_fp_info", "kicad_model_check", "kicad_show_3d_chip", "kicad_lint_library"],+ pitfalls: &["The 3d view needs a model on the footprint whose file resolves: a part without one is ok:false, not drawn empty.", "KiCad's 3D viewer settings can hide whole classes of bodies (kicad_3d_viewer_settings): a body that resolves but does not show is hidden there.", "A symbol with several units gives one symbol picture per unit (paths)."],+}];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ match command {+ "kicad_library_views" => Some(library_views(state, args)),+ _ => None,+ }+}++const VIEWS: [&str; 4] = ["symbol", "footprint", "3d", "device"];+const FP_LAYERS: &str = "F.Cu,B.Cu,F.SilkS,F.Fab,F.CrtYd";++fn is_pretty(p: &Path) -> bool {+ p.extension().map(|e| e.eq_ignore_ascii_case("pretty")).unwrap_or(false)+}++/// The .svg files kicad-cli wrote into `dir`, in name order.+fn svgs(dir: &Path) -> Vec<PathBuf> {+ let mut v: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|e| e == "svg").unwrap_or(false)).collect();+ v.sort();+ v+}++fn png_of(svg: &Path, png: &Path, width: u32) -> Result<(), String> {+ let bytes = std::fs::read(svg).map_err(|e| format!("{}: {e}", norm(svg)))?;+ let r = sch_render::rasterize(&bytes, width)?;+ sch_render::save_png(&r.pixmap, png)+}++fn safe(name: &str) -> String {+ name.chars().map(|c| if c.is_alphanumeric() || "-_.+".contains(c) { c } else { '_' }).collect()+}++struct Fp {+ id: String,+ path: PathBuf,+ text: String,+}++fn library_views(state: &mut State, args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "library"]).map(|s| PathBuf::from(s.trim_end_matches(['/', '\\']))) else {+ return fail("missing_arg", "libraryPath is required", "A symbol library (.kicad_sym) or a footprint library (.pretty folder).");+ };+ let parts: Vec<String> = match args.get("parts") {+ Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(String::from).collect(),+ Some(Value::String(s)) => s.split(';').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(),+ _ => Vec::new(),+ };+ if parts.is_empty() {+ return fail("missing_arg", "parts is required", "parts: [\"name\", ...], the symbol or footprint names in the library.");+ }+ let views: Vec<String> = match args.get("views").and_then(Value::as_array) {+ Some(a) => a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect(),+ None => VIEWS.iter().map(|s| s.to_string()).collect(),+ };+ if let Some(bad) = views.iter().find(|v| !VIEWS.contains(&v.as_str())) {+ return fail("bad_args", format!("unknown view {bad}"), "views are symbol, footprint, 3d and device.");+ }+ let pretty = is_pretty(&lib);+ let sym_lib = if pretty { arg_str(args, "symbolLibraryPath").map(PathBuf::from) } else { Some(lib.clone()) };+ let fp_lib = if pretty { Some(lib.clone()) } else { arg_str(args, "footprintLibraryPath").map(|s| PathBuf::from(s.trim_end_matches(['/', '\\']))) };+ if !lib.exists() {+ return fail("file_not_found", format!("{} does not exist", norm(&lib)), "Use the desktop's absolute path of the .kicad_sym or the .pretty folder.");+ }+ let info = state.kicad_info();+ let Some(inst) = info.primary() else {+ let mut v = fail("not_installed", "KiCad not installed", "The views are drawn by kicad-cli.");+ v["alternatives"] = alternatives();+ return v;+ };+ let cli_path = PathBuf::from(&inst.kicad_cli);+ let width = args.get("width").and_then(Value::as_u64).unwrap_or(800).clamp(64, 4096) as u32;+ let stem = lib.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "library".into());+ let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| lib.parent().unwrap_or(Path::new(".")).join(format!("{stem}-views")));+ if let Err(e) = std::fs::create_dir_all(&out_dir) {+ return fail("output_dir", format!("cannot create {}: {e}", norm(&out_dir)), "Pass an outputDir the desktop user can write.");+ }+ let tmp = std::env::temp_dir().join(format!("adom-kicad-views-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)));+ let sym_text = sym_lib.as_ref().map(|p| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", norm(p))));+ let lib_ctx = crate::verbs_pcb_sync::lib_ctx(&info);+ let model_ctx = model_check::Ctx::from_info(&info);+ let run = |argv: &[&str]| cli::run(&cli_path, argv, Duration::from_secs(120));+ let mut captured = Vec::new();+ for part in &parts {+ let sym: Result<Value, String> = match &sym_text {+ None => Err("a footprint library has no symbols: pass symbolLibraryPath (a .kicad_sym with a symbol of the same name)".into()),+ Some(Err(e)) => Err(e.clone()),+ Some(Ok(t)) => symedit::symbol_info(t, part),+ };+ let fp: Result<Fp, String> = (|| {+ let (dir, id, name) = if pretty {+ (lib.clone(), format!("{stem}:{part}"), part.clone())+ } else {+ let s = sym.as_ref().map_err(|e| format!("no symbol to take the footprint from ({e})"))?;+ let field = s["fields"]["Footprint"].as_str().filter(|f| !f.is_empty()).ok_or(format!("the symbol {part} names no footprint (its Footprint field is empty)"))?;+ let (_, name) = field.split_once(':').unwrap_or(("", field));+ match &fp_lib {+ Some(d) => (d.clone(), field.to_string(), name.to_string()),+ None => {+ let f = kicad_core::pcb_sync::resolve_footprint(field, lib.parent().unwrap_or(Path::new(".")), &lib_ctx)?;+ (f.parent().map(Path::to_path_buf).unwrap_or_default(), field.to_string(), name.to_string())+ }+ }+ };+ let path = dir.join(format!("{name}.kicad_mod"));+ let text = std::fs::read_to_string(&path).map_err(|_| format!("{name}.kicad_mod is not in {}", norm(&dir)))?;+ Ok(Fp { id, path, text })+ })();+ let base = safe(part);+ for view in &views {+ let mut entry = json!({"part": part, "view": view, "ok": false});+ let got: Result<Value, String> = match view.as_str() {+ "symbol" => sym.as_ref().map_err(String::clone).and_then(|s| {+ let d = tmp.join(format!("sym-{base}"));+ let _ = std::fs::create_dir_all(&d);+ let (d_s, lib_s) = (norm(&d), norm(sym_lib.as_ref().unwrap()));+ let r = run(&["sym", "export", "svg", "--symbol", part, "-o", &d_s, &lib_s]);+ let files = svgs(&d);+ if files.is_empty() {+ return Err(format!("kicad-cli sym export svg wrote nothing (rc {}): {}", r.exit_code, r.stderr.trim()));+ }+ let mut paths = Vec::new();+ for (i, f) in files.iter().enumerate() {+ let png = out_dir.join(if files.len() == 1 { format!("{base}-symbol.png") } else { format!("{base}-symbol-unit{}.png", i + 1) });+ png_of(f, &png, width)?;+ paths.push(norm(&png));+ }+ Ok(json!({"path": paths[0], "paths": paths, "units": s["unitCount"], "pins": s["pinCount"]}))+ }),+ "footprint" => fp.as_ref().map_err(String::clone).and_then(|f| {+ let d = tmp.join(format!("fp-{base}"));+ let _ = std::fs::create_dir_all(&d);+ let name = f.path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ let (d_s, dir_s) = (norm(&d), norm(f.path.parent().unwrap_or(Path::new("."))));+ let r = run(&["fp", "export", "svg", "--fp", &name, "--sp", "--layers", FP_LAYERS, "-o", &d_s, &dir_s]);+ let svg = svgs(&d).into_iter().next().ok_or(format!("kicad-cli fp export svg wrote nothing (rc {}): {}", r.exit_code, r.stderr.trim()))?;+ let png = out_dir.join(format!("{base}-footprint.png"));+ png_of(&svg, &png, width)?;+ let pads = fpedit::info(&f.text).map(|i| i["padCount"].clone()).unwrap_or(Value::Null);+ Ok(json!({"path": norm(&png), "footprint": f.id, "pads": pads}))+ }),+ "3d" => fp.as_ref().map_err(String::clone).and_then(|f| {+ let check = model_check::check_footprint_file(&model_ctx, &f.id, &f.path, None)?;+ let models = check["models"].as_array().cloned().unwrap_or_default();+ if models.is_empty() {+ return Err(format!("{} has no 3D body: attach a model to the footprint first", f.id));+ }+ if !models.iter().any(|m| m["exists"] == true) {+ let p: Vec<&str> = models.iter().filter_map(|m| m["resolved"].as_str().or(m["path"].as_str())).collect();+ return Err(format!("the model file does not resolve: {}", p.join(", ")));+ }+ let resolved: Vec<Option<String>> = models.iter().map(|m| m["resolved"].as_str().map(String::from)).collect();+ let body = library_views::body_box(&f.text, &resolved);+ let (board, zoom) = library_views::view_board(&f.text, &f.id, body.as_ref())?;+ let d = tmp.join(format!("3d-{base}"));+ let _ = std::fs::create_dir_all(&d);+ let pcb = d.join("view.kicad_pcb");+ std::fs::write(&pcb, board).map_err(|e| format!("{}: {e}", norm(&pcb)))?;+ let png = out_dir.join(format!("{base}-3d.png"));+ let (w, h, png_s, pcb_s, z) = (width.to_string(), (width * 3 / 4).to_string(), norm(&png), norm(&pcb), zoom.to_string());+ let _ = std::fs::remove_file(&png);+ let r = cli::run(&cli_path, &["pcb", "render", "--side", "top", "--rotate", "-45,0,45", "--quality", "basic", "--background", "opaque", "--zoom", &z, "--width", &w, "--height", &h, "--output", &png_s, &pcb_s], Duration::from_secs(240));+ if r.exit_code != 0 || !png.is_file() {+ return Err(format!("kicad-cli pcb render failed (rc {}): {}", r.exit_code, r.stderr.trim()));+ }+ Ok(json!({"path": png_s, "footprint": f.id, "models": models.len(), "bodyHeightMm": body.map(|b| (b.max[2] * 100.0).round() / 100.0)}))+ }),+ _ => match (&sym, &fp) {+ (Err(e), _) => Err(format!("no symbol: {e}")),+ (_, Err(e)) => Err(format!("no footprint: {e}")),+ (Ok(s), Ok(f)) => (|| {+ let pads = fpedit::info(&f.text)?["pads"].as_array().cloned().unwrap_or_default();+ let map = library_views::pin_map(s["pins"].as_array().map(Vec::as_slice).unwrap_or(&[]), &pads);+ let svg = out_dir.join(format!("{base}-device.svg"));+ let sym_stem = sym_lib.as_ref().and_then(|p| p.file_stem()).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ std::fs::write(&svg, library_views::device_svg(part, &format!("{sym_stem}:{part}"), &f.id, &map)).map_err(|e| format!("{}: {e}", norm(&svg)))?;+ Ok(json!({"path": norm(&svg), "footprint": f.id, "matches": map["ok"], "pinsWithoutPad": map["pinsWithoutPad"], "padsWithoutPin": map["padsWithoutPin"]}))+ })(),+ },+ };+ match got {+ Ok(v) => {+ entry["ok"] = json!(true);+ for (k, x) in v.as_object().into_iter().flatten() {+ entry[k] = x.clone();+ }+ }+ Err(e) => entry["error"] = json!(e),+ }+ captured.push(entry);+ }+ }+ let _ = std::fs::remove_dir_all(&tmp);+ let good = captured.iter().filter(|c| c["ok"] == true).count();+ json!({"success": good > 0, "captured": captured, "outputDir": norm(&out_dir),+ "output": format!("{good} of {} view(s) captured in {}", parts.len() * views.len(), norm(&out_dir)),+ "_hint": "Pull the images (ab pull_file) and look at them: pin names on the symbol, pad numbers on the footprint, the body over its pads; the device entry lists any pin without a pad."})+}
rust/crates/kicad-bridge/src/verbs_standard.rs+57@@ -122,6 +122,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_copper_layers", "kicad_layers_in_use", "kicad_layer_stack"], pitfalls: &["A file read does not see unsaved edits in an open editor."], },+ Verb {+ name: "kicad_set_layer_visibility",+ summary: "Show or hide one layer in the open PCB editor (KiCad's own layer visibility, through its API), read back.",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 30,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"layer\": \"F.SilkS\" | \"layerId\": 5 (an id from kicad_layers), \"displayed\"?: true}",+ example: "kicad_set_layer_visibility {\"layer\":\"F.SilkS\",\"displayed\":false}",+ hint: "data {name, displayed, was}; changed says whether it moved. A view setting only: the board file does not change. kicad_layers lists every layer with displayed as the editor shows it now.",+ related: &["kicad_layers", "kicad_refresh_board_views", "kicad_screenshot_all"],+ pitfalls: &["Needs the board open in the PCB editor with the IPC API on."],+ }, 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.",@@ -148,6 +158,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_component_pins" => view(state, args, "component_pins"), "kicad_layers" => layers(state, args), "kicad_silk_state" => silk(state, args),+ "kicad_set_layer_visibility" => set_layer_visibility(state, args), _ => return None, }) }@@ -293,6 +304,52 @@ fn companion(state: &mut State, args: &Value, which: Companion) -> Value { } } +/// `kicad_set_layer_visibility`: by name, or by kicad_layers' id mapped to its name on this board.+#[cfg(feature = "ipc")]+fn set_layer_visibility(state: &mut State, args: &Value) -> Value {+ use kicad_core::{detect, ipc};+ let mut a = args.clone();+ // no filePath: the one board the PCB editor has open+ 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 change."),+ _ => return fail("no_open_board", "no board is open in KiCad's PCB editor", "Open one with kicad_open_board, or pass filePath."),+ }+ }+ if a.get("layer").is_none() {+ let Some(id) = args.get("layerId").and_then(Value::as_i64) else {+ return fail("missing_arg", "layer (a name) or layerId (from kicad_layers) is required", "kicad_layers lists them.");+ };+ let r = match read(state, &json!({"filePath": a.get("filePath"), "source": "live"})) { Ok(r) => r, Err(e) => return e };+ let table = kicad_core::board_reads::layers(&r.text, None).unwrap_or(Value::Null);+ match table["layers"].as_array().into_iter().flatten().find(|l| l["id"].as_i64() == Some(id)).and_then(|l| l["name"].as_str()) {+ Some(n) => a["layer"] = json!(n),+ None => return fail("unknown_layer", format!("no layer {id} on this board"), "kicad_layers lists the ids."),+ }+ }+ 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::set_layer_visibility(&ctx, &a) {+ Ok(mut v) => {+ if let Some(id) = args.get("layerId") {+ v["data"]["layerId"] = id.clone();+ }+ v["_hint"] = json!("A view setting only; the board file does not change. kicad_layers shows displayed for every layer.");+ v+ }+ Err(e) => ipc::error_response(&e),+ }+}++#[cfg(not(feature = "ipc"))]+fn set_layer_visibility(_state: &mut State, _args: &Value) -> Value {+ fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Layer visibility needs the open PCB editor's API.")+}+ /// The install's 3D model library: where `${KICAD<n>_3DMODEL_DIR}` points. fn models_dir(state: &mut State) -> Option<std::path::PathBuf> { let info = state.kicad_info();
rust/crates/kicad-core/src/ipc.rs+21@@ -1056,6 +1056,27 @@ pub fn visible_layer_names(ctx: &Ctx, args: &Value) -> RResult<Vec<String>> { Ok(s.client.get_visible_layers().map_err(map_err)?.into_iter().map(|l| l.name).collect()) } +/// `kicad_set_layer_visibility`: show or hide one layer in the open PCB editor (KiCad's own visible+/// set, through the API), read back.+pub fn set_layer_visibility(ctx: &Ctx, args: &Value) -> RResult<Value> {+ let name = args["layer"].as_str().filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "layer names the layer (F.SilkS, In1.Cu ...)"))?;+ let shown = args.get("displayed").and_then(Value::as_bool).unwrap_or(true);+ let id = BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")))?;+ let s = connect(ctx, args)?;+ let mut ids: Vec<i32> = s.client.get_visible_layers().map_err(map_err)?.into_iter().map(|l| l.id).collect();+ let before = ids.contains(&id);+ ids.retain(|i| *i != id);+ if shown {+ ids.push(id);+ }+ s.client.set_visible_layers(ids).map_err(map_err)?;+ let now = s.client.get_visible_layers().map_err(map_err)?.iter().any(|l| l.id == id);+ if now != shown {+ return Err(RoutingError::new("not_applied", format!("KiCad still {} {name}", if now { "shows" } else { "hides" })).with("displayed", json!(now)));+ }+ Ok(json!({"success": true, "data": {"name": name, "displayed": now, "was": before}, "changed": before != now}))+}+ /// `kicad_ripup_nets`: every unlocked track, arc and via of the named nets, removed live as one /// Undo step through remove_route (its dry run, DRC preview and read-back included). pub fn ripup_nets(ctx: &Ctx, args: &Value) -> RResult<Value> {
rust/crates/kicad-core/src/lib.rs+1@@ -51,6 +51,7 @@ pub mod project; pub mod fpedit; pub mod symedit; pub mod land_pattern;+pub mod library_views; pub mod libops; pub mod board_build; pub mod board_checks;
rust/crates/kicad-core/src/library_views.rsadded+151@@ -0,0 +1,151 @@+//! Pictures of library parts (the EDA Bridge Standard's library_views), the pure half: the device+//! view (KiCad has no device; it is the symbol's pins mapped to the footprint's pads, drawn as an+//! SVG table) and the one-part board the 3D view renders. The verb runs kicad-cli for the symbol+//! and footprint SVGs and the 3D render.++use serde_json::{json, Value};++/// Every pin of the symbol (number, name) against the footprint's pad numbers: the rows, the pins+/// with no pad and the pads with no pin. Pads without a number (mounting holes, NPTH) are skipped.+pub fn pin_map(pins: &[Value], pads: &[Value]) -> Value {+ let pad_nums: Vec<String> = pads.iter().filter_map(|p| p["number"].as_str()).filter(|n| !n.is_empty()).map(String::from).collect();+ let mut rows = Vec::new();+ let mut pin_nums: Vec<String> = Vec::new();+ for p in pins {+ let Some(n) = p["number"].as_str() else { continue };+ if pin_nums.iter().any(|m| m == n) {+ continue;+ }+ pin_nums.push(n.to_string());+ rows.push(json!({"pin": n, "name": p["name"].as_str().unwrap_or(""), "pad": pad_nums.iter().any(|q| q == n)}));+ }+ rows.sort_by_key(|r| crate::placement::ref_key(r["pin"].as_str().unwrap_or("")));+ let mut no_pad: Vec<&str> = rows.iter().filter(|r| r["pad"] == false).filter_map(|r| r["pin"].as_str()).collect();+ no_pad.sort_by_key(|n| crate::placement::ref_key(n));+ let mut no_pin: Vec<String> = pad_nums.iter().filter(|q| !pin_nums.contains(q)).cloned().collect();+ no_pin.sort_by_key(|n| crate::placement::ref_key(n));+ no_pin.dedup();+ json!({"rows": rows, "pinsWithoutPad": no_pad, "padsWithoutPin": no_pin, "ok": no_pad.is_empty() && no_pin.is_empty()})+}++fn esc(s: &str) -> String {+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)+}++/// The device view: a table of pin, pin name and pad, a missing pad in red, then the pads no pin+/// lands on.+pub fn device_svg(part: &str, symbol: &str, footprint: &str, map: &Value) -> String {+ let rows = map["rows"].as_array().cloned().unwrap_or_default();+ let extra = map["padsWithoutPin"].as_array().cloned().unwrap_or_default();+ let (row_h, w) = (22.0, 520.0);+ let h = 96.0 + row_h * (rows.len() as f64 + if extra.is_empty() { 0.0 } else { 2.0 });+ let mut s = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Segoe UI, Arial, sans-serif\" font-size=\"14\">\n<rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n");+ s += &format!("<text x=\"16\" y=\"26\" font-size=\"17\" font-weight=\"bold\">{}</text>\n", esc(part));+ s += &format!("<text x=\"16\" y=\"46\" fill=\"#555\">{} → {}</text>\n", esc(symbol), esc(footprint));+ s += "<text x=\"16\" y=\"78\" font-weight=\"bold\">Pin</text><text x=\"96\" y=\"78\" font-weight=\"bold\">Name</text><text x=\"380\" y=\"78\" font-weight=\"bold\">Pad</text>\n";+ let mut y = 78.0;+ for r in &rows {+ y += row_h;+ let ok = r["pad"] == true;+ let pin = esc(r["pin"].as_str().unwrap_or(""));+ s += &format!("<text x=\"16\" y=\"{y}\">{pin}</text><text x=\"96\" y=\"{y}\">{}</text><text x=\"380\" y=\"{y}\" fill=\"{}\">{}</text>\n",+ esc(r["name"].as_str().unwrap_or("")), if ok { "#1a7f37" } else { "#cf222e" }, if ok { pin.clone() } else { "no pad".into() });+ }+ if !extra.is_empty() {+ y += row_h * 1.5;+ let list: Vec<&str> = extra.iter().filter_map(Value::as_str).collect();+ s += &format!("<text x=\"16\" y=\"{y}\" fill=\"#cf222e\">Pads with no pin: {}</text>\n", esc(&list.join(", ")));+ }+ s += "</svg>\n";+ s+}++/// The union of the footprint's shown model boxes in its own frame (y up, z up from the surface),+/// `resolved` being each `(model ...)`'s file in order (None: it did not resolve).+pub fn body_box(fp_text: &str, resolved: &[Option<String>]) -> Option<crate::bodies::Bounds> {+ let fp = crate::model_check::parse_sexpr(fp_text)?;+ let mut out: Option<crate::bodies::Bounds> = None;+ for (m, f) in crate::bodies::model_refs(&fp).iter().zip(resolved) {+ let Some(f) = f.as_deref().filter(|_| !m.hidden) else { continue };+ let Ok(b) = crate::bodies::file_bounds(std::path::Path::new(f)) else { continue };+ let b = crate::bodies::in_footprint(&b, m);+ out = Some(match out {+ None => b,+ Some(o) => crate::bodies::Bounds { min: [0, 1, 2].map(|i| o.min[i].min(b.min[i])), max: [0, 1, 2].map(|i| o.max[i].max(b.max[i])) },+ });+ }+ out+}++/// A one-part board for the 3D view, and the camera zoom that keeps the body in the frame: the+/// footprint at the origin of a 2-layer board whose outline is its courtyard, pads and body grown by+/// 1 mm. kicad-cli frames the board, so a body taller than the board is wide zooms out.+pub fn view_board(fp_text: &str, lib_id: &str, body: Option<&crate::bodies::Bounds>) -> Result<(String, f64), String> {+ let info = crate::fpedit::info(fp_text)?;+ let (mut x0, mut y0, mut x1, mut y1) = match info["courtyard"].as_object() {+ Some(c) => (c["min"][0].as_f64().unwrap_or(-1.0), c["min"][1].as_f64().unwrap_or(-1.0), c["max"][0].as_f64().unwrap_or(1.0), c["max"][1].as_f64().unwrap_or(1.0)),+ None => (-1.0, -1.0, 1.0, 1.0),+ };+ for p in info["pads"].as_array().into_iter().flatten() {+ let (x, y) = (p["at"][0].as_f64().unwrap_or(0.0), p["at"][1].as_f64().unwrap_or(0.0));+ let r = p["size"].as_array().map(|s| s.iter().filter_map(Value::as_f64).fold(0.0, f64::max) / 2.0).unwrap_or(0.0);+ (x0, y0, x1, y1) = (x0.min(x - r), y0.min(y - r), x1.max(x + r), y1.max(y + r));+ }+ let mut height = 0.0;+ if let Some(b) = body {+ // the body's box is y up; the board's frame is y down+ (x0, y0, x1, y1) = (x0.min(b.min[0]), y0.min(-b.max[1]), x1.max(b.max[0]), y1.max(-b.min[1]));+ height = b.max[2] - b.min[2].min(0.0);+ }+ let side = (x1 - x0).max(y1 - y0) + 2.0;+ let zoom = (side / (side + 0.9 * height)).clamp(0.3, 0.9);+ let blank = crate::project::blank_board(None, 2)?;+ let (outline, _) = crate::board_build::outline_items(&json!({"rect": [x0 - 1.0, y0 - 1.0, x1 - x0 + 2.0, y1 - y0 + 2.0]}), false)?;+ let part = crate::board_build::Placed { reference: "U1".into(), lib_id: lib_id.into(), text: fp_text.into(), x: 0.0, y: 0.0, rotation: 0.0, back: false, value: None };+ let text = crate::board_build::build(&blank, &outline, &[part], &Default::default())?.0;+ Ok((text, (zoom * 100.0).round() / 100.0))+}++#[cfg(test)]+mod tests {+ use super::*;++ const SOT23: &str = "(footprint \"SOT-23\" (layer \"F.Cu\")+ (property \"Reference\" \"REF**\" (at 0 -2.4 0) (layer \"F.SilkS\") (uuid \"a\") (effects (font (size 1 1) (thickness 0.15))))+ (property \"Value\" \"SOT-23\" (at 0 2.4 0) (layer \"F.Fab\") (uuid \"b\") (effects (font (size 1 1) (thickness 0.15))))+ (fp_rect (start -1.9 -1.7) (end 1.9 1.7) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"c\"))+ (pad \"1\" smd roundrect (at -1.1 -0.95) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"d\"))+ (pad \"2\" smd roundrect (at -1.1 0.95) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"e\"))+ (pad \"3\" smd roundrect (at 1.1 0) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"f\"))+ (pad \"\" np_thru_hole circle (at 0 0) (size 0.5 0.5) (drill 0.5) (layers \"*.Cu\" \"*.Mask\") (uuid \"g\"))+ (model \"${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0))))";++ #[test]+ fn pins_are_mapped_to_pads_both_ways() {+ let pins = vec![json!({"number": "2", "name": "S"}), json!({"number": "1", "name": "G"}), json!({"number": "4", "name": "TAB"}), json!({"number": "1", "name": "G"})];+ let pads = crate::fpedit::info(SOT23).unwrap()["pads"].as_array().unwrap().clone();+ let m = pin_map(&pins, &pads);+ assert_eq!(m["rows"].as_array().unwrap().len(), 3, "a pin repeated across body styles counts once");+ assert_eq!(m["rows"][0]["pin"], "1");+ assert_eq!(m["pinsWithoutPad"], json!(["4"]));+ assert_eq!(m["padsWithoutPin"], json!(["3"]), "the unnumbered NPTH is not a pad without a pin");+ assert_eq!(m["ok"], false);+ let svg = device_svg("Q<1>", "Lib:Q", "Lib:SOT-23", &m);+ assert!(svg.contains("Q<1>") && svg.contains("no pad") && svg.contains("Pads with no pin: 3"));+ assert!(crate::sch_render::rasterize(svg.as_bytes(), 260).is_ok(), "the SVG parses");+ }++ #[test]+ fn the_view_board_holds_the_part_inside_an_outline() {+ let (t, zoom) = view_board(SOT23, "Lib:SOT-23", None).unwrap();+ assert_eq!(zoom, 0.9);+ // a body 20 mm tall, wider than the pads: the board covers it and the camera backs off+ let tall = crate::bodies::Bounds { min: [-3.0, -2.0, -3.0], max: [3.0, 2.0, 17.0] };+ let (t2, z2) = view_board(SOT23, "Lib:SOT-23", Some(&tall)).unwrap();+ assert!(t2.contains("(start -4 -3)") && z2 < 0.4, "{z2}");+ let parsed = crate::pcb::parse_pcb_text(&t, "v.kicad_pcb").unwrap();+ assert_eq!(crate::pcb::pads(&parsed).len(), 4);+ assert!(t.contains("Edge.Cuts") && t.contains("SOT-23.step"));+ assert!(t.contains("(start -2.9 -2.7)"), "courtyard grown by 1 mm");+ }+}
skills/kicad-board-checks/SKILL.md+14−1@@ -1,7 +1,7 @@ --- name: kicad-board-checks description: >-- Check a KiCad board the way the EDA Bridge Standard checks every board, and fix what the checks find: return paths over gaps in the reference copper, copper balance (bow and twist), impedance track widths from the stack-up, current capacity (IPC-2221), net lengths and differential-pair skew, matched-length groups, 3D heights and enclosure fit, part orientation, free space for a part, symbols against footprints, net classes from net names, thermal vias in an exposed pad, a via fence for EMC, reference designators placed clear of the pads, the BOM (one line per MPN, JLCPCB, Mouser and DigiKey files), JLCPCB assembly tiers, a JLCPCB order in one call, and one HTML board report with a picture. Works from the .kicad_pcb file without KiCad running, or live from the PCB editor. Trigger words: board review, check my board, design review, return path, ground plane gap, split plane, copper balance, board warp, bow and twist, impedance, 50 ohm trace width, 90 ohm USB pair, trace width for current, how many amps, can jlcpcb make it, fab limits, fab check, design rules check against a fab, IPC-2221, net length, length matching, pair skew, DDR length, height check, will it fit the enclosure, part too tall, 3D collision, rotate parts, find space, where can this part go, pin pad mismatch, symbol footprint mismatch, net classes, auto classes, power class, thermal vias, exposed pad vias, via stitching, via fence, EMC fence, RF fence, board report, report html, bom, bill of materials, jlcpcb bom, cpl, pick and place, lcsc numbers, missing mpn, jlcpcb assembly, economic or standard assembly, order from jlcpcb, gerber zip, order the board, designators, silkscreen labels, reference designators on pads, label placement, panelize, mouse bites, tabs, v-score, panel, build a board, test board, read schematic file, length tune, length matching, serpentine, meander, match length, skew, silkscreen prep, silk on pads, component class, room, keep parts together.+ Check a KiCad board the way the EDA Bridge Standard checks every board, and fix what the checks find: return paths over gaps in the reference copper, copper balance (bow and twist), impedance track widths from the stack-up, current capacity (IPC-2221), net lengths and differential-pair skew, matched-length groups, 3D heights and enclosure fit, part orientation, free space for a part, symbols against footprints, net classes from net names, thermal vias in an exposed pad, a via fence for EMC, reference designators placed clear of the pads, the BOM (one line per MPN, JLCPCB, Mouser and DigiKey files), JLCPCB assembly tiers, a JLCPCB order in one call, and one HTML board report with a picture. Works from the .kicad_pcb file without KiCad running, or live from the PCB editor. Trigger words: board review, check my board, design review, return path, ground plane gap, split plane, copper balance, board warp, bow and twist, impedance, 50 ohm trace width, 90 ohm USB pair, trace width for current, how many amps, can jlcpcb make it, fab limits, fab check, design rules check against a fab, IPC-2221, net length, length matching, pair skew, DDR length, height check, will it fit the enclosure, part too tall, 3D collision, rotate parts, find space, where can this part go, pin pad mismatch, symbol footprint mismatch, net classes, auto classes, power class, thermal vias, exposed pad vias, via stitching, via fence, EMC fence, RF fence, board report, report html, bom, bill of materials, jlcpcb bom, cpl, pick and place, lcsc numbers, missing mpn, jlcpcb assembly, economic or standard assembly, order from jlcpcb, gerber zip, order the board, designators, silkscreen labels, reference designators on pads, label placement, panelize, mouse bites, tabs, v-score, panel, build a board, test board, read schematic file, length tune, length matching, serpentine, meander, match length, skew, silkscreen prep, silk on pads, component class, room, keep parts together, library views, picture of a part, symbol and footprint images, review a library part, pin to pad map, hide a layer, layer visibility. --- # Board checks@@ -113,6 +113,19 @@ netlist (footprints by Library:Name or from a .pretty `libraryPath`), read back lays one footprint or a `;` list on a new or existing board to look at. `kicad_read_sheet_file` reads a schematic sheet's parts, fields and pins straight from the file. +## Library parts, pictured++`kicad_library_views {libraryPath, parts}` draws each part the way a reviewer checks it before it is used,+headless through kicad-cli, nothing opened: the symbol (`<part>-symbol.png`, one per unit), the footprint with+its pad numbers (`-footprint.png`), the 3D body seated on its pads (`-3d.png`, an isometric render of a+one-part board, zoomed out for a tall body) and the device view (`-device.svg`, every symbol pin against a pad:+`pinsWithoutPad` and `padsWithoutPin` should both be empty). From a `.kicad_sym` the footprint is the symbol's+Footprint field; from a `.pretty` folder pass `symbolLibraryPath` for the symbol. A footprint without a model,+or whose model file does not resolve, gets `ok:false` for 3d, never an empty picture.++`kicad_set_layer_visibility {layer | layerId, displayed}` shows or hides one layer in the open PCB editor and+reads it back. It is view state the user keeps seeing: restore what you hid.+ ## The report `kicad_board_report {projectPath}` runs DRC, ERC, the board against the schematic, symbols against
skills/kicad-bridge-test/run_verb_tests.py+19−2@@ -195,7 +195,8 @@ def matrix(w: dict) -> dict: **{v: dict(phase=10, scenario=True) for v in ("fab_check", "copper_balance", "return_path", "impedance", "current_capacity", "net_lengths", "check_orientation", "find_space", "height_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report", "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",- "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic")},+ "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",+ "library_views")}, "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),@@ -389,7 +390,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")},+ "menu_items", "3d_view", "refresh_board_views", "set_layer_visibility")}, # ---- 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,@@ -953,6 +954,11 @@ def main(): record("board_pads", r, ok(r) and len(r.get("pads") or []) > 0) r = step("routing_validate", {"filePath": L}, chain=False, timeout=300) record("routing_validate", r, ok(r) and "unconnected" in r)+ # view state only: hide the front silkscreen, read it back, show it again+ off = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": False}, chain=False)+ on = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": True}, chain=False)+ record("set_layer_visibility", {"off": off, "on": on}, ok(off) and (off.get("data") or {}).get("displayed") is False and ok(on) and (on.get("data") or {}).get("displayed") is True,+ "F.SilkS was not hidden and shown again") # route_net: the shortest same-side two-pad hop among the unrouted nets, tried as a dry run first remaining = set(st.get("netsRemaining") or [])@@ -1534,6 +1540,17 @@ def main(): own("build_board", r, ok(r) and r.get("padsWired") == 4 and r.get("wrongPins") == [] and (r.get("counts") or {}).get("components") == 2, "the board was not built and wired") r = C("test_board", {"libraryPath": lp["libraryPath"], "footprintName": "SOIC-8_test", "pcbPath": w["out"] + "/built/test.kicad_pcb", "assigns": "1=GND"}) own("test_board", r, ok(r) and r.get("count") == 1 and r.get("saved") is True and r.get("wrongPins") == [], "the footprint was not placed on a test board")+ # 1.0.71: pictures of library parts: KiCad's own regulators (symbol, footprint, 3d, device), and a footprint with no body+ inst = (ab(a.target, "kicad_status", {}, timeout=60).get("install") or {})+ reg = (inst.get("baseDir") or "") + "/share/kicad/symbols/Regulator_Linear.kicad_sym"+ r = C("library_views", {"libraryPath": reg, "parts": ["AMS1117-3.3"], "outputDir": w["out"] + "/views"}, 300)+ cap = {c.get("view"): c for c in r.get("captured") or []}+ C("footprint_from_land_pattern", dict(lp, name="SOIC-8_nobody", body={"w": 3.9, "l": 4.9}))+ nb = C("library_views", {"libraryPath": lp["libraryPath"], "parts": ["SOIC-8_nobody"], "views": ["footprint", "3d"], "outputDir": w["out"] + "/views"}, 300)+ nbc = {c.get("view"): c for c in nb.get("captured") or []}+ own("library_views", {"stock": r, "nobody": nb}, ok(r) and all((cap.get(v) or {}).get("ok") for v in ("symbol", "footprint", "3d", "device")) and (cap["device"].get("matches") is True)+ and (nbc.get("footprint") or {}).get("ok") and (nbc.get("3d") or {}).get("ok") is False and "no 3D body" in str((nbc.get("3d") or {}).get("error")),+ "not every view of a stock part was captured, or a body-less footprint drew a 3d view") r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300) record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular", "no mouse-bite panel")