← Commit history
rust/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"]