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 #358: kicad-lane: 1.0.91, cleanup_libs, build_symbol_modes, add_symbol_art (#29)
8 files changed
+525−40
rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_libedit.rs+184rust/crates/kicad-bridge/src/verbs_unsupported.rs−33rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/libclean.rs+111rust/crates/kicad-core/src/symedit.rs+209−1skills/kicad-bridge-test/run_verb_tests.py+16−2rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.90"+version = "1.0.91" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.90"+version = "1.0.91" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.90"+version = "1.0.91" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.90"+version = "1.0.91" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_libedit.rs+184@@ -32,6 +32,36 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_fp_info", "kicad_fp_create", "kicad_lint_library"], pitfalls: &["boards that already use the footprint keep their own copy: update them from the library in KiCad (Update Footprints from Library)", "graphics replace/delete touch shapes only, never text on that layer"], },+ Verb {+ name: "kicad_add_symbol_art",+ summary: "Draw artwork (polylines: a chip outline, a logo) inside a library symbol's body rectangle, scaled to fit with a margin and centred, keeping its aspect; the art comes as point lists or from another symbol's polylines (adom-symbol's baked chip outline).",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+ input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"STM32G431\", \"art\"?: [[[x, y], ...], ...] | \"artFrom\"?: {\"libraryPath\": \"C:/.../x.kicad_sym\", \"symbolName\": \"...\"}, \"clear\"?: true, \"marginMm\"?: 0.5, \"dryRun\"?: false}",+ example: "kicad_add_symbol_art {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"U_CHIP\",\"art\":[[[0,0],[10,0],[10,5],[0,5],[0,0]]]}",+ hint: "bodyRectMm is the rectangle the art was fitted in (the symbol's largest), scale the factor applied; polylines and points count what was drawn. kicad_symbol_mode shows the result as KiCad plots it. A .bak of the library is kept.",+ related: &["kicad_symbol_mode", "kicad_build_symbol_modes", "kicad_sym_info"],+ pitfalls: &["clear (the default) removes the unit's existing polylines, arcs and circles first: hand-drawn lines on the symbol go too; the body rectangle and the pins stay.", "A symbol with no body rectangle has nothing to fit the art in, and is refused."],+ },+ Verb {+ name: "kicad_build_symbol_modes",+ summary: "Give a library symbol its De Morgan alternate body (KiCad's second display mode): the alternate drawing for every unit, the standard body's pins copied with any pin shapes changed (inverted inputs), and the symbol marked as having two body styles.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+ input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"74X00\", \"graphics\": [{\"rectangle\": {\"start\": [-2.54, 2.54], \"end\": [2.54, -2.54]}} | {\"polyline\": [[x, y], ...]} | {\"circle\": {\"center\", \"radius\"}} | {\"arc\": {\"start\", \"mid\", \"end\"}}, with \"fill\"?: \"none|background|outline\", \"width\"?], \"pinShapes\"?: {\"1\": \"inverted\"}, \"spec\"?: \"C:/.../modes.json\" ({graphics, pinShapes}), \"replace\"?: false, \"dryRun\"?: false}",+ example: "kicad_build_symbol_modes {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"NAND2\",\"graphics\":[{\"circle\":{\"center\":[0,0],\"radius\":2.54},\"fill\":\"background\"}],\"pinShapes\":{\"1\":\"inverted\",\"2\":\"inverted\"}}",+ hint: "units lists the units that got an alternate body. Graphics are in mm, y up as the symbol editor shows them. kicad_symbol_mode {mode: 1} shows the result as KiCad plots it. A .bak of the library is kept.",+ related: &["kicad_symbol_mode", "kicad_sym_create", "kicad_sym_info"],+ pitfalls: &["KiCad symbols have two body styles: there is one alternate, not N modes.", "The alternate body's graphics are the same for every unit."],+ },+ Verb {+ name: "kicad_cleanup_libs",+ summary: "Clean a symbol or footprint library: the parts no given project uses, and parts that repeat another part under a different name (same pins and graphics, or same pads and graphics). A plan by default; apply removes the names you list.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\" | \"C:/.../Mine.pretty\", \"projects\"?: [\"C:/.../board.kicad_pro\" | a project folder], \"nickname\"?: \"Mine\" (default the file's name), \"apply\"?: false, \"remove\"?: [\"R_old\"], \"force\"?: false}",+ example: "kicad_cleanup_libs {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"projects\":[\"C:/Users/john/proj/board.kicad_pro\"]}",+ hint: "unused lists parts none of the projects places as Nickname:Name (a symbol another symbol extends counts as used); duplicates lists groups of identical parts. Without projects nothing is called unused. apply:true with remove takes those names out: a symbol library keeps a .bak, a footprint file moves to <Lib>.pretty.removed beside the library, so either can be put back. A name that is neither unused nor a duplicate is refused unless force.",+ related: &["kicad_lint_library", "kicad_sym_delete", "kicad_fp_info"],+ pitfalls: &["Usage is read from the projects you give: a part another project uses still shows as unused here.", "Parts are matched as Nickname:Name: pass nickname when the library is registered under another name than its file's."],+ }, Verb { name: "kicad_plate_vias", summary: "Make every drilled pad in a footprint library a plated hole through every copper layer (thru_hole, *.Cu *.Mask): a thru_hole pad on only some copper layers, or an smd pad with a drill (thermal vias written as surface pads). Numbered unplated holes are listed, and plated only with includeNpth.",@@ -198,6 +228,9 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_fp_edit" => fp_edit(state, args), "kicad_fp_info" => fp_info(state, args), "kicad_plate_vias" => plate_vias(args),+ "kicad_cleanup_libs" => cleanup_libs(args),+ "kicad_build_symbol_modes" => build_symbol_modes(args),+ "kicad_add_symbol_art" => add_symbol_art(args), "kicad_sym_create" => sym_create(args), "kicad_sym_delete" => sym_delete(args), "kicad_sym_info" => sym_info(state, args),@@ -584,6 +617,157 @@ mod tests { // Library housekeeping (the standard's core tier) // --------------------------------------------------------------------------- +/// `kicad_add_symbol_art`: polylines fitted into the symbol's body rectangle.+fn add_symbol_art(args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "The .kicad_sym that holds the symbol.");+ };+ let Some(name) = arg_any(args, &["symbolName", "symbol", "name"]) else {+ return fail("missing_arg", "symbolName is required", "The symbol to draw in.");+ };+ let art: Vec<Vec<(f64, f64)>> = if let Some(from) = args.get("artFrom") {+ let (Some(fl), Some(fname)) = (from["libraryPath"].as_str(), from["symbolName"].as_str()) else {+ return fail("bad_arg", "artFrom is {libraryPath, symbolName}", "The symbol whose polylines are the art.");+ };+ match std::fs::read_to_string(fl).map_err(|e| e.to_string()).and_then(|t| kicad_core::symedit::symbol_polylines(&t, fname)) {+ Ok(a) => a,+ Err(e) => return fail("symbol_not_found", e, "artFrom names a symbol that draws polylines."),+ }+ } else {+ args.get("art").and_then(Value::as_array).map(|ls| ls.iter().filter_map(|l| l.as_array()).map(|l| l.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect()).collect()).unwrap_or_default()+ };+ let Ok(text) = std::fs::read_to_string(&lib) else { return fail("file_not_found", format!("cannot read {}", norm(&lib)), "A .kicad_sym file.") };+ let clear = args.get("clear") != Some(&json!(false));+ let margin = args.get("marginMm").and_then(Value::as_f64).unwrap_or(0.5).max(0.0);+ let (out, rep) = match kicad_core::symedit::add_symbol_art(&text, name, &art, clear, margin) {+ Ok(r) => r,+ Err(e) => return fail("bad_args", e, "art: [[[x, y], ...], ...] (two points or more each), or artFrom {libraryPath, symbolName}."),+ };+ let dry = args.get("dryRun") == Some(&json!(true));+ if !dry {+ let bak = lib.with_extension("kicad_sym.bak");+ if let Err(e) = std::fs::copy(&lib, &bak).map_err(|e| e.to_string()).and_then(|_| kicad_core::libraries::write_atomic(&lib, out.as_bytes())) {+ return fail("write_failed", format!("{}: {e}", norm(&lib)), "The library must be writable.");+ }+ }+ let mut v = json!({"success": true, "dryRun": dry, "symbol": name, "output": format!("{} polyline(s) drawn in {name}'s body{}", rep["polylines"], if dry { " (dry run: nothing written)" } else { "" }),+ "_hint": "kicad_symbol_mode shows the symbol as KiCad plots it."});+ for (k, x) in rep.as_object().into_iter().flatten() {+ v[k] = x.clone();+ }+ v+}++/// `kicad_build_symbol_modes`: the De Morgan alternate body (graphics and pin shapes given, or in a spec file).+fn build_symbol_modes(args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "The .kicad_sym that holds the symbol.");+ };+ let Some(name) = arg_any(args, &["symbolName", "symbol", "name"]) else {+ return fail("missing_arg", "symbolName is required", "The symbol to give an alternate body.");+ };+ let spec: Value = match arg_str(args, "spec") {+ Some(sp) => match std::fs::read_to_string(sp).ok().and_then(|t| serde_json::from_str(&t).ok()) {+ Some(v) => v,+ None => return fail("bad_arg", format!("spec {sp} is not a readable JSON file"), "spec holds {graphics, pinShapes}."),+ },+ None => args.clone(),+ };+ let graphics: Vec<Value> = spec.get("graphics").and_then(Value::as_array).cloned().unwrap_or_default();+ let shapes = spec.get("pinShapes").and_then(Value::as_object).cloned().unwrap_or_default();+ let Ok(text) = std::fs::read_to_string(&lib) else { return fail("file_not_found", format!("cannot read {}", norm(&lib)), "A .kicad_sym file.") };+ let (out, units) = match kicad_core::symedit::build_alternate_body(&text, name, &graphics, &shapes, args.get("replace") == Some(&json!(true))) {+ Ok(r) => r,+ Err(e) => return fail("bad_args", e, "graphics: [{rectangle|polyline|circle|arc ...}] in mm; pass replace:true to rewrite an existing alternate body."),+ };+ let dry = args.get("dryRun") == Some(&json!(true));+ if !dry {+ let bak = lib.with_extension("kicad_sym.bak");+ if let Err(e) = std::fs::copy(&lib, &bak).map_err(|e| e.to_string()).and_then(|_| kicad_core::libraries::write_atomic(&lib, out.as_bytes())) {+ return fail("write_failed", format!("{}: {e}", norm(&lib)), "The library must be writable.");+ }+ }+ json!({"success": true, "dryRun": dry, "symbol": name, "units": units, "modes": [0, 1],+ "output": format!("{name}: De Morgan body written for {} unit(s){}", units.len(), if dry { " (dry run: nothing written)" } else { "" }),+ "_hint": "kicad_symbol_mode {mode: 1} shows the alternate body as KiCad plots it."})+}++/// `kicad_cleanup_libs`: unused and duplicate parts of one library, a plan unless apply removes the names listed.+fn cleanup_libs(args: &Value) -> Value {+ use kicad_core::libclean;+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "A .kicad_sym or a .pretty folder.");+ };+ let nick = arg_str(args, "nickname").map(String::from).unwrap_or_else(|| lib.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());+ let projects: Vec<PathBuf> = args.get("projects").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(PathBuf::from).collect()).unwrap_or_default();+ let uses = (!projects.is_empty()).then(|| projects.iter().flat_map(|p| libclean::project_uses(if p.is_dir() { p } else { p.parent().unwrap_or(Path::new(".")) })).collect::<std::collections::BTreeSet<String>>());+ let is_sym = lib.extension().map(|e| e == "kicad_sym").unwrap_or(false);+ let (plan, sym_text, fp_files) = if is_sym {+ let Ok(text) = std::fs::read_to_string(&lib) else { return fail("file_not_found", format!("cannot read {}", norm(&lib)), "A .kicad_sym file.") };+ match libclean::plan_symbols(&text, &nick, uses.as_ref()) {+ Ok(p) => (p, Some(text), Vec::new()),+ Err(e) => return fail("parse_failed", e, "kicad_lint_library says what is wrong with the file."),+ }+ } else if lib.is_dir() {+ let mut files: Vec<(String, String)> = std::fs::read_dir(&lib).into_iter().flatten().flatten().map(|e| e.path())+ .filter(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false))+ .filter_map(|p| Some((p.file_stem()?.to_string_lossy().into_owned(), std::fs::read_to_string(&p).ok()?))).collect();+ files.sort();+ (libclean::plan_footprints(&files, &nick, uses.as_ref()), None, files)+ } else {+ return fail("bad_arg", format!("{} is neither a .kicad_sym nor a .pretty folder", norm(&lib)), "Pass one library.");+ };+ let remove: Vec<String> = args.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();+ let mut v = json!({"success": true, "plan": true, "library": norm(&lib), "nickname": nick, "projectsRead": projects.iter().map(|p| norm(p)).collect::<Vec<_>>()});+ for (k, x) in plan.as_object().into_iter().flatten() {+ v[k] = x.clone();+ }+ if args.get("apply") != Some(&json!(true)) || remove.is_empty() {+ v["output"] = json!(format!("{} part(s): {} unused, {} duplicate group(s){}", plan["parts"], plan["unused"].as_array().map_or(0, |a| a.len()), plan["duplicates"].as_array().map_or(0, |a| a.len()), if uses.is_none() { " (no projects given: nothing can be called unused)" } else { "" }));+ v["_hint"] = json!("A plan: nothing removed. apply:true with remove:[names] takes them out (a .bak, or <Lib>.pretty.removed, keeps them).");+ return v;+ }+ let candidates: std::collections::BTreeSet<String> = plan["unused"].as_array().into_iter().flatten().chain(plan["duplicates"].as_array().into_iter().flatten().flat_map(|g| g.as_array().into_iter().flatten()))+ .filter_map(|x| x.as_str().map(String::from)).collect();+ let force = args.get("force") == Some(&json!(true));+ let strays: Vec<&String> = remove.iter().filter(|n| !candidates.contains(*n)).collect();+ if !strays.is_empty() && !force {+ return fail("not_a_candidate", format!("{} is neither unused nor a duplicate", strays.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")), "Remove only what the plan lists, or pass force:true.");+ }+ let mut removed = Vec::new();+ if let Some(mut text) = sym_text {+ for n in &remove {+ match kicad_core::symedit::delete_symbol(&text, n, false) {+ Ok((t, _)) => { text = t; removed.push(n.clone()); }+ Err(e) => return fail("edit_failed", format!("{n}: {e}"), "Nothing was written."),+ }+ }+ let bak = lib.with_extension("kicad_sym.bak");+ if let Err(e) = std::fs::copy(&lib, &bak).map_err(|e| e.to_string()).and_then(|_| kicad_core::libraries::write_atomic(&lib, text.as_bytes())) {+ return fail("write_failed", format!("{}: {e}", norm(&lib)), "The library must be writable.");+ }+ v["backup"] = json!(norm(&bak));+ } else {+ let parked = PathBuf::from(format!("{}.removed", lib.to_string_lossy().trim_end_matches(['/', '\\'])));+ let _ = std::fs::create_dir_all(&parked);+ for n in &remove {+ if !fp_files.iter().any(|(f, _)| f == n) {+ continue;+ }+ let from = lib.join(format!("{n}.kicad_mod"));+ if let Err(e) = std::fs::rename(&from, parked.join(format!("{n}.kicad_mod"))) {+ return fail("write_failed", format!("{}: {e}", norm(&from)), "The library must be writable.");+ }+ removed.push(n.clone());+ }+ v["parkedIn"] = json!(norm(&parked));+ }+ v["plan"] = json!(false);+ v["removed"] = json!(removed);+ v["output"] = json!(format!("{} part(s) removed from {}", removed.len(), norm(&lib)));+ v+}+ /// `kicad_plate_vias`: the standard's plate_vias over a .pretty (or one .kicad_mod). fn plate_vias(args: &Value) -> Value { let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {
rust/crates/kicad-bridge/src/verbs_unsupported.rs−33@@ -9,9 +9,6 @@ use crate::util::*; /// (verb, why KiCad has no form of it, what to call instead). const WHY: &[(&str, &str, &str)] = &[- ("kicad_add_symbol_art", "The chip outline is baked into the KiCad symbol itself by adom-symbol: KiCad shows it with no second copy to draw (Altium needs this because its symbol is rebuilt from KiCad's).", "kicad_symbol_mode or kicad_library_views (see the symbol as KiCad draws it)"),- ("kicad_cleanup_libs", "A new KiCad library starts empty, so there is no factory-default stray to remove.", "kicad_sym_delete / kicad_lint_library"),- ("kicad_build_symbol_modes", "KiCad symbols have two body styles (standard and De Morgan), not N display modes, and nothing in KiCad reads Altium's symbol_modes spec.", "kicad_sym_create (the standard body), kicad_symbol_mode (see either body style)"), ("kicad_license", "KiCad is GPL-3.0-or-later and has no licence page to drive.", "kicad_license_status"), ("kicad_signin", "KiCad has no account to sign in to.", "kicad_license_status"), ("kicad_connect_workspace", "KiCad has no cloud workspace to connect to.", "kicad_install_library (KiCad's libraries are local files and PCM packages)"),@@ -26,36 +23,6 @@ const WHY: &[(&str, &str, &str)] = &[ ]; pub static VERBS: &[Verb] = &[- Verb {- name: "kicad_add_symbol_art",- summary: "Draw adom-symbol's chip outline into a symbol (the standard's add_symbol_art). Not in KiCad: The chip outline is baked into the KiCad symbol itself by adom-symbol: KiCad shows it with no second copy to draw (Altium needs this because its symbol is rebuilt from KiCad's).",- mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,- input: "{}",- example: "kicad_add_symbol_art {}",- hint: "Answers not_supported with why and instead: kicad_symbol_mode or kicad_library_views (see the symbol as KiCad draws it).",- related: &[],- pitfalls: &[],- },- Verb {- name: "kicad_cleanup_libs",- summary: "Remove a new library's factory-default stray part (the standard's cleanup_libs). Not in KiCad: A new KiCad library starts empty, so there is no factory-default stray to remove.",- mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,- input: "{}",- example: "kicad_cleanup_libs {}",- hint: "Answers not_supported with why and instead: kicad_sym_delete / kicad_lint_library.",- related: &[],- pitfalls: &[],- },- Verb {- name: "kicad_build_symbol_modes",- summary: "Build a symbol with N display modes from a symbol_modes spec (the standard's build_symbol_modes). Not in KiCad: KiCad symbols have two body styles (standard and De Morgan), not N display modes, and nothing in KiCad reads Altium's symbol_modes spec.",- mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,- input: "{}",- example: "kicad_build_symbol_modes {}",- hint: "Answers not_supported with why and instead: kicad_sym_create (the standard body), kicad_symbol_mode (see either body style).",- related: &[],- pitfalls: &[],- }, Verb { name: "kicad_license", summary: "Drive the tool's licence page (the standard's license). Not in KiCad: KiCad is GPL-3.0-or-later and has no licence page to drive.",
rust/crates/kicad-core/src/lib.rs+1@@ -67,6 +67,7 @@ pub mod board_edit; // Schematic hierarchy, analysis, render and design review (kicad_sch_hierarchy and friends). pub mod sch_model; pub mod variants;+pub mod libclean; pub mod sch_hier; pub mod sch_check; pub mod sch_review;
rust/crates/kicad-core/src/libclean.rsadded+111@@ -0,0 +1,111 @@+//! The standard's cleanup_libs for KiCad: a library's parts that no given project uses, and parts that repeat+//! another part under a different name. A plan by default; removing takes the names to remove.+use std::collections::{BTreeMap, BTreeSet};+use std::path::Path;++use serde_json::{json, Value};++use crate::schematic::Document;++/// Every `Lib:Name` a project's schematic sheets place (`lib_id`) and its boards use (`footprint`), from the files in+/// the project's folder.+pub fn project_uses(project_dir: &Path) -> BTreeSet<String> {+ let mut out = BTreeSet::new();+ for e in std::fs::read_dir(project_dir).into_iter().flatten().flatten() {+ let p = e.path();+ let ext = p.extension().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default();+ let key = match ext.as_str() { "kicad_sch" => "(lib_id \"", "kicad_pcb" => "(footprint \"", _ => continue };+ let Ok(t) = std::fs::read_to_string(&p) else { continue };+ let mut rest = t.as_str();+ while let Some(i) = rest.find(key) {+ let after = &rest[i + key.len()..];+ if let Some(q) = after.find('"') {+ out.insert(after[..q].to_string());+ }+ rest = &after[after.find('"').unwrap_or(0) + 1..];+ }+ }+ out+}++/// A symbol's text with its own name taken out, so two symbols that differ only in name compare equal.+fn symbol_shape(lib_text: &str, name: &str) -> Option<String> {+ let flat = crate::schematic::flatten_lib_symbol(lib_text, name).ok()?;+ let mut t = flat.to_text();+ for n in [format!("\"{name}\""), format!("\"{name}_")] {+ t = t.replace(&n, "\"@_");+ }+ // the Value field usually repeats the name+ Some(t.split_whitespace().collect::<Vec<_>>().join(" "))+}++/// A footprint's text without its name, uuids and timestamps.+fn footprint_shape(text: &str, name: &str) -> String {+ let t = text.replacen(&format!("(footprint \"{name}\""), "(footprint \"@\"", 1).replace(&format!("\"{name}\""), "\"@\"");+ t.lines().filter(|l| !l.trim_start().starts_with("(uuid") && !l.trim_start().starts_with("(tedit") && !l.trim_start().starts_with("(generator")).map(str::trim).collect::<Vec<_>>().join(" ")+}++/// The plan for one symbol library: `uses` are the projects' `Lib:Name` ids (None: no project given, so nothing can be+/// called unused), `nick` the library's nickname in those ids.+pub fn plan_symbols(lib_text: &str, nick: &str, uses: Option<&BTreeSet<String>>) -> Result<Value, String> {+ let doc = Document::parse(lib_text)?;+ let syms: Vec<(String, Option<String>)> = doc.root.children().iter().filter(|c| c.head() == Some("symbol"))+ .filter_map(|c| Some((c.arg(0)?, c.child_value("extends")))).collect();+ let extended: BTreeSet<&str> = syms.iter().filter_map(|(_, e)| e.as_deref()).collect();+ let unused: Vec<&str> = match uses {+ Some(u) => syms.iter().map(|(n, _)| n.as_str()).filter(|n| !u.contains(&format!("{nick}:{n}")) && !extended.contains(n)).collect(),+ None => Vec::new(),+ };+ Ok(json!({"kind": "symbols", "parts": syms.len(), "unused": unused, "duplicates": groups(syms.iter().filter_map(|(n, _)| symbol_shape(lib_text, n).map(|s| (n.clone(), s))))}))+}++/// The plan for one footprint library (`files`: name and .kicad_mod text).+pub fn plan_footprints(files: &[(String, String)], nick: &str, uses: Option<&BTreeSet<String>>) -> Value {+ let unused: Vec<&str> = match uses {+ Some(u) => files.iter().map(|(n, _)| n.as_str()).filter(|n| !u.contains(&format!("{nick}:{n}"))).collect(),+ None => Vec::new(),+ };+ json!({"kind": "footprints", "parts": files.len(), "unused": unused, "duplicates": groups(files.iter().map(|(n, t)| (n.clone(), footprint_shape(t, n))))})+}++/// Names whose shapes are identical, in groups of two or more (first name first).+fn groups(items: impl Iterator<Item = (String, String)>) -> Vec<Vec<String>> {+ let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for (n, s) in items {+ by.entry(s).or_default().push(n);+ }+ let mut g: Vec<Vec<String>> = by.into_values().filter(|v| v.len() > 1).collect();+ g.sort();+ g+}++#[cfg(test)]+mod tests {+ use super::*;++ fn sym(name: &str, body: &str) -> String {+ format!("\t(symbol \"{name}\"\n\t\t(property \"Reference\" \"R\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(property \"Value\" \"{name}\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(symbol \"{name}_0_1\"\n\t\t\t{body}\n\t\t)\n\t)\n")+ }++ #[test]+ fn unused_and_duplicate_symbols_are_found_and_extended_ones_kept() {+ let rect = "(rectangle (start -1 1) (end 1 -1))";+ let lib = format!("(kicad_symbol_lib\n\t(version 20241209)\n{}{}{}\t(symbol \"R_alias\"\n\t\t(extends \"R_base\")\n\t)\n)\n", sym("R_base", rect), sym("R_copy", rect), sym("C_other", "(circle (center 0 0) (radius 1))"));+ let uses: BTreeSet<String> = ["Mine:R_alias".to_string()].into();+ let p = plan_symbols(&lib, "Mine", Some(&uses)).unwrap();+ assert_eq!(p["unused"], json!(["R_copy", "C_other"]), "{p}");+ assert_eq!(p["duplicates"], json!([["R_base", "R_copy"]]));+ let none = plan_symbols(&lib, "Mine", None).unwrap();+ assert_eq!(none["unused"], json!([]));+ }++ #[test]+ fn duplicate_footprints_ignore_names_and_uuids() {+ let fp = |n: &str, u: &str| format!("(footprint \"{n}\"\n\t(uuid \"{u}\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))\n)\n");+ let files = vec![("A".to_string(), fp("A", "1")), ("B".to_string(), fp("B", "2")), ("C".to_string(), fp("C", "3").replace("(size 1 1)", "(size 2 2)"))];+ let uses: BTreeSet<String> = ["Lib:A".to_string()].into();+ let p = plan_footprints(&files, "Lib", Some(&uses));+ assert_eq!(p["duplicates"], json!([["A", "B"]]));+ assert_eq!(p["unused"], json!(["B", "C"]));+ }+}
rust/crates/kicad-core/src/symedit.rs+209−1@@ -8,7 +8,7 @@ use std::collections::BTreeMap; use serde_json::{json, Value}; use crate::pcb::num;-use crate::schematic::{flatten_lib_symbol, parse_node, pretty, quote, Document};+use crate::schematic::{flatten_lib_symbol, parse_node, pretty, quote, Document, Node}; pub const SYM_VERSION: &str = "20251024"; pub const PIN_TYPES: &[&str] = &["input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect"];@@ -267,6 +267,179 @@ pub fn delete_symbol(lib_text: &str, name: &str, force: bool) -> Result<(String, Ok((out, children)) } +/// One graphic of a body, as KiCad writes it: {rectangle: {start, end}} | {polyline: [[x, y], ...]} | {circle: {center,+/// radius}} | {arc: {start, mid, end}}, mm, y up as the symbol editor shows it; `fill` none | background | outline.+fn graphic_text(g: &Value) -> Result<String, String> {+ let n = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).ok_or_else(|| format!("bad point in {g}"));+ let pt = |v: &Value| -> Result<String, String> { Ok(format!("{} {}", num(n(v, 0)?), num(n(v, 1)?))) };+ let fill = g.get("fill").and_then(Value::as_str).unwrap_or("none");+ let width = g.get("width").and_then(Value::as_f64).unwrap_or(0.254);+ let tail = format!("(stroke (width {}) (type default)) (fill (type {fill}))", num(width));+ if let Some(r) = g.get("rectangle") {+ return Ok(format!("(rectangle (start {}) (end {}) {tail})", pt(&r["start"])?, pt(&r["end"])?));+ }+ if let Some(p) = g.get("polyline").and_then(Value::as_array) {+ let pts: Vec<String> = p.iter().map(|q| pt(q).map(|s| format!("(xy {s})"))).collect::<Result<_, _>>()?;+ if pts.len() < 2 {+ return Err("a polyline needs two points or more".into());+ }+ return Ok(format!("(polyline (pts {}) {tail})", pts.join(" ")));+ }+ if let Some(c) = g.get("circle") {+ return Ok(format!("(circle (center {}) (radius {}) {tail})", pt(&c["center"])?, num(c["radius"].as_f64().ok_or("a circle needs radius")?)));+ }+ if let Some(a) = g.get("arc") {+ return Ok(format!("(arc (start {}) (mid {}) (end {}) {tail})", pt(&a["start"])?, pt(&a["mid"])?, pt(&a["end"])?));+ }+ Err(format!("a graphic is rectangle, polyline, circle or arc: {g}"))+}++/// The standard's build_symbol_modes in KiCad's terms: give `name` its De Morgan alternate body (body style 2). Each+/// unit gets `graphics` (the alternate's drawing) and a copy of its standard body's pins, with `pin_shapes` ({number:+/// "inverted" | "line" | ...}) changing a pin's shape in the alternate; the symbol is marked `(body_styles demorgan)`.+/// An existing alternate body is replaced only with `replace`. Returns the new text and the units written.+pub fn build_alternate_body(lib_text: &str, name: &str, graphics: &[Value], pin_shapes: &serde_json::Map<String, Value>, replace: bool) -> Result<(String, Vec<u32>), String> {+ let gfx: Vec<String> = graphics.iter().map(graphic_text).collect::<Result<_, _>>()?;+ if gfx.is_empty() {+ return Err("graphics is required: the alternate body's drawing".into());+ }+ let mut doc = Document::parse(lib_text)?;+ let syms = top_symbols(&doc);+ let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else {+ return Err(format!("no symbol {name} in the library"));+ };+ let sym = &mut doc.root.children_mut().unwrap()[*idx];+ if sym.child_value("extends").is_some() {+ return Err(format!("{name} extends another symbol: give the alternate body to that one"));+ }+ let base = name.to_string();+ let subs: Vec<(u32, u32)> = sym.children().iter().filter(|c| c.head() == Some("symbol")).filter_map(|c| {+ let n = c.arg(0)?;+ let rest = n.strip_prefix(&format!("{base}_"))?;+ let (u, st) = rest.rsplit_once('_')?;+ Some((u.parse().ok()?, st.parse().ok()?))+ }).collect();+ if subs.iter().any(|(_, st)| *st == 2) {+ if !replace {+ return Err(format!("{name} already has an alternate body: pass replace:true to rewrite it"));+ }+ if let Some(c) = sym.children_mut() {+ c.retain(|n| !(n.head() == Some("symbol") && n.arg(0).map(|a| a.ends_with("_2") && a.starts_with(&format!("{base}_"))).unwrap_or(false)));+ }+ }+ let mut units: Vec<u32> = subs.iter().filter(|(u, st)| *st == 1 && *u >= 1).map(|(u, _)| *u).collect();+ units.sort();+ units.dedup();+ if units.is_empty() {+ units.push(1);+ }+ let mut new_subs = Vec::new();+ for u in &units {+ let mut pins = Vec::new();+ for src in [format!("{base}_{u}_1"), format!("{base}_0_1")] {+ if let Some(n) = sym.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(src.as_str())) {+ for p in n.children().iter().filter(|c| c.head() == Some("pin")) {+ let mut p = p.clone();+ if let Some(shape) = p.child_value("number").and_then(|num| pin_shapes.get(&num)).and_then(Value::as_str) {+ p.set_arg(1, Node::atom(shape));+ }+ pins.push(p.to_text());+ }+ }+ }+ let text = format!("(symbol \"{base}_{u}_2\" {} {})", gfx.join(" "), pins.join(" "));+ let mut node = parse_node(&text)?;+ pretty(&mut node, 2);+ new_subs.push(node);+ }+ let kids = sym.children_mut().unwrap();+ if !kids.iter().any(|c| c.head() == Some("body_styles")) {+ let mut b = parse_node("(body_styles demorgan)")?;+ pretty(&mut b, 2);+ let at = kids.iter().position(|c| c.head() == Some("property")).unwrap_or(kids.len());+ kids.insert(at, b);+ }+ let at = kids.iter().rposition(|c| c.head() == Some("symbol")).map(|i| i + 1).unwrap_or(kids.len());+ for (k, n) in new_subs.into_iter().enumerate() {+ kids.insert(at + k, n);+ }+ let out = doc.to_text();+ crate::lint::check_symbol_lib_structure(&out)?;+ Ok((out, units))+}++/// The polylines a symbol draws (each a list of points, mm), from all its units: the art another symbol carries.+pub fn symbol_polylines(lib_text: &str, name: &str) -> Result<Vec<Vec<(f64, f64)>>, String> {+ let flat = flatten_lib_symbol(lib_text, name)?;+ let mut out = Vec::new();+ for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {+ for pl in sub.children().iter().filter(|c| c.head() == Some("polyline")) {+ let pts: Vec<(f64, f64)> = pl.child("pts").map(|p| p.children().iter().filter(|q| q.head() == Some("xy")).filter_map(|q| Some((q.num(0)?, q.num(1)?))).collect()).unwrap_or_default();+ if pts.len() >= 2 {+ out.push(pts);+ }+ }+ }+ Ok(out)+}++/// The standard's add_symbol_art: draw `art` (polylines, any units) inside the symbol's body rectangle, scaled to fit+/// with `margin` mm all round and centred, keeping its aspect. With `clear`, the unit's existing polylines, arcs and+/// circles go first (the body rectangle and the pins stay). Returns the new text and {bodyRectMm, polylines, points}.+pub fn add_symbol_art(lib_text: &str, name: &str, art: &[Vec<(f64, f64)>], clear: bool, margin: f64) -> Result<(String, Value), String> {+ let lines: Vec<&Vec<(f64, f64)>> = art.iter().filter(|l| l.len() >= 2).collect();+ if lines.is_empty() {+ return Err("art is required: polylines of two points or more".into());+ }+ let mut doc = Document::parse(lib_text)?;+ let syms = top_symbols(&doc);+ let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else { return Err(format!("no symbol {name} in the library")) };+ let sym = &mut doc.root.children_mut().unwrap()[*idx];+ // the body: the largest rectangle in any standard-body unit+ let mut best: Option<(usize, (f64, f64, f64, f64))> = None;+ for (k, sub) in sym.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol") && c.arg(0).map(|a| a.ends_with("_1")).unwrap_or(false)) {+ for r in sub.children().iter().filter(|c| c.head() == Some("rectangle")) {+ let (Some(a), Some(b)) = (r.child("start"), r.child("end")) else { continue };+ let (x0, y0, x1, y1) = (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0), b.num(0).unwrap_or(0.0), b.num(1).unwrap_or(0.0));+ let rect = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));+ let area = (rect.2 - rect.0) * (rect.3 - rect.1);+ if best.map(|(_, b)| area > (b.2 - b.0) * (b.3 - b.1)).unwrap_or(true) {+ best = Some((k, rect));+ }+ }+ }+ let Some((unit_idx, body)) = best else { return Err(format!("{name} has no body rectangle to draw in")) };+ let (ax0, ay0, ax1, ay1) = lines.iter().flat_map(|l| l.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)));+ let (aw, ah) = ((ax1 - ax0).max(1e-9), (ay1 - ay0).max(1e-9));+ let (bw, bh) = ((body.2 - body.0 - 2.0 * margin).max(0.1), (body.3 - body.1 - 2.0 * margin).max(0.1));+ let k = (bw / aw).min(bh / ah);+ let (cx, cy) = ((body.0 + body.2) / 2.0, (body.1 + body.3) / 2.0);+ let (acx, acy) = ((ax0 + ax1) / 2.0, (ay0 + ay1) / 2.0);+ let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+ let mut nodes = Vec::new();+ let mut points = 0;+ for l in &lines {+ let pts: Vec<String> = l.iter().map(|(x, y)| format!("(xy {} {})", num(r4(cx + (x - acx) * k)), num(r4(cy + (y - acy) * k)))).collect();+ points += pts.len();+ let mut n = parse_node(&format!("(polyline (pts {}) (stroke (width 0.1524) (type default)) (fill (type none)))", pts.join(" ")))?;+ pretty(&mut n, 3);+ nodes.push(n);+ }+ let unit = &mut sym.children_mut().unwrap()[unit_idx];+ let kids = unit.children_mut().unwrap();+ if clear {+ kids.retain(|c| !matches!(c.head(), Some("polyline") | Some("arc") | Some("circle") | Some("bezier")));+ }+ let at = kids.iter().position(|c| c.head() == Some("pin")).unwrap_or(kids.len());+ let count = nodes.len();+ for (i, n) in nodes.into_iter().enumerate() {+ kids.insert(at + i, n);+ }+ let out = doc.to_text();+ crate::lint::check_symbol_lib_structure(&out)?;+ Ok((out, json!({"bodyRectMm": [r4(body.0), r4(body.1), r4(body.2), r4(body.3)], "polylines": count, "points": points, "scale": r4(k)})))+}+ /// Pins, units, fields and flags of one symbol (derived symbols resolved through `extends`). pub fn symbol_info(lib_text: &str, name: &str) -> Result<Value, String> { let doc = Document::parse(lib_text)?;@@ -402,4 +575,39 @@ mod tests { assert_eq!(i["fields"]["Value"], "R2"); assert_eq!(i["pinCount"], 2, "pins resolved through extends"); }++ #[test]+ fn an_alternate_body_copies_the_pins_and_marks_the_symbol_demorgan() {+ let args = json!({"reference": "U", "pins": [{"number": 1, "name": "A", "type": "input"}, {"number": 2, "name": "Y", "type": "output", "side": "right"}]});+ let (lib, _) = add_symbol(&empty_library(), "BUF", &args, false).unwrap();+ let g = vec![json!({"polyline": [[-2.54, 2.54], [2.54, 0], [-2.54, -2.54], [-2.54, 2.54]], "fill": "background"})];+ let shapes: serde_json::Map<String, Value> = [("1".to_string(), json!("inverted"))].into_iter().collect();+ let (t, units) = build_alternate_body(&lib, "BUF", &g, &shapes, false).unwrap();+ assert_eq!(units, vec![1]);+ assert!(t.contains("(body_styles demorgan)") && t.contains("(symbol \"BUF_1_2\""), "{t}");+ let i = symbol_info(&t, "BUF").unwrap();+ let alt: Vec<&Value> = i["pins"].as_array().unwrap().iter().filter(|p| p["bodyStyle"] == 2).collect();+ assert_eq!(alt.len(), 2);+ assert_eq!(alt.iter().find(|p| p["number"] == "1").unwrap()["shape"], "inverted");+ assert!(build_alternate_body(&t, "BUF", &g, &shapes, false).is_err(), "an existing alternate needs replace");+ assert!(build_alternate_body(&t, "BUF", &g, &shapes, true).is_ok());+ assert!(build_alternate_body(&lib, "BUF", &[json!({"blob": 1})], &shapes, false).is_err());+ }++ #[test]+ fn art_is_fitted_inside_the_body_rectangle() {+ let args = json!({"reference": "U", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "side": "right"}]});+ let (lib, _) = add_symbol(&empty_library(), "CHIP", &args, false).unwrap();+ // a 100 x 50 outline in its own units+ let art = vec![vec![(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0), (0.0, 0.0)], vec![(10.0, 25.0), (90.0, 25.0)]];+ let (t, rep) = add_symbol_art(&lib, "CHIP", &art, true, 0.5).unwrap();+ assert_eq!(rep["polylines"], 2);+ let body = rep["bodyRectMm"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();+ let drawn = symbol_polylines(&t, "CHIP").unwrap();+ for (x, y) in drawn.iter().flatten() {+ assert!(*x >= body[0] + 0.49 && *x <= body[2] - 0.49 && *y >= body[1] + 0.49 && *y <= body[3] - 0.49, "({x}, {y}) outside {body:?}");+ }+ assert_eq!(symbol_info(&t, "CHIP").unwrap()["pinCount"], 2, "pins stay");+ assert!(add_symbol_art(&lib, "CHIP", &[vec![(0.0, 0.0)]], true, 0.5).is_err());+ } }
skills/kicad-bridge-test/run_verb_tests.py+16−2@@ -128,7 +128,7 @@ def matrix(w: dict) -> dict: # ---- phase 1: read-only / meta (no KiCad needed) "describe": dict(phase=1, args={}, check="verbs>=47"), **{v: dict(phase=1, args={}, check="explains_unsupported") for v in (- "add_symbol_art", "cleanup_libs", "build_symbol_modes", "license", "signin", "connect_workspace", "agent_start", "agent_stop",+ "license", "signin", "connect_workspace", "agent_start", "agent_stop", "agent_status", "agent_recover", "selftest_script_error", "extension_ping", "install_extension", "install_receipt")}, "status": dict(phase=1, args={}), "readiness": dict(phase=1, args={}),@@ -202,7 +202,7 @@ def matrix(w: dict) -> dict: "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", "library_views", "generate_body", "plan_route", "export_spice", "simulate",- "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode")},+ "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs")}, "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),@@ -1686,6 +1686,20 @@ def main(): kbase = ((ab(a.target, "kicad_readiness", {}, timeout=60).get("installs") or [{}])[0]).get("baseDir") or "C:/Program Files/KiCad/10.0" m1 = C("symbol_mode", {"libraryPath": kbase + "/share/kicad/symbols/74xx.kicad_sym", "symbol": "74LS00", "mode": 1}, 120) own("symbol_mode", m1, ok(m1) and m1.get("modes") == [0, 1] and str(m1.get("png") or "").endswith(".png"), "74LS00's De Morgan body was not plotted")+ # 1.0.91 (#29): a scratch symbol library: two identical symbols (cleanup_libs finds the pair), a De Morgan body+ # KiCad then plots as mode 1, and art fitted inside the body+ sl = w["out"] + "/Modes.kicad_sym"+ for nm in ("NAND_A", "NAND_B"):+ C("sym_create", {"libraryPath": sl, "name": nm, "reference": "U", "overwrite": True,+ "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}, {"number": 3, "name": "Y", "side": "right"}]}, 60)+ cl = C("cleanup_libs", {"libraryPath": sl}, 60)+ own("cleanup_libs", cl, ok(cl) and cl.get("plan") is True and ["NAND_A", "NAND_B"] in (cl.get("duplicates") or []), "the two identical symbols were not found as duplicates")+ bm = C("build_symbol_modes", {"libraryPath": sl, "symbolName": "NAND_A", "graphics": [{"circle": {"center": [0, 0], "radius": 2.54}, "fill": "background"}],+ "pinShapes": {"1": "inverted", "2": "inverted"}, "replace": True}, 60)+ bm1 = C("symbol_mode", {"libraryPath": sl, "symbol": "NAND_A", "mode": 1}, 120)+ own("build_symbol_modes", {"build": bm, "plot": bm1}, ok(bm) and bm.get("units") == [1] and ok(bm1) and bm1.get("modes") == [0, 1], "KiCad did not plot the alternate body written")+ art = C("add_symbol_art", {"libraryPath": sl, "symbolName": "NAND_B", "art": [[[0, 0], [40, 0], [40, 20], [0, 20], [0, 0]]]}, 60)+ own("add_symbol_art", art, ok(art) and art.get("polylines") == 1 and isinstance(art.get("bodyRectMm"), list), "the art was not drawn in the body") # 1.0.88: the standard's sch_zoom: where parts sit on a sheet and KiCad's plot framed on them sz = C("sch_zoom", {"sheet": w["sch"], "designators": ["U1", "C1", "ZZ9"]}, 240) own("sch_zoom", sz, ok(sz) and {p.get("designator") for p in sz.get("parts") or []} == {"U1", "C1"} and sz.get("notOnSheet") == ["ZZ9"]