← All Pull Requests

kicad-lane: 1.0.91, cleanup_libs, build_symbol_modes, add_symbol_art (#29) #53

Merged opened by Colby Knox 2026-10-30

1.0.91: three of the standard's library verbs that 1.0.90 refused are now real (#29).

  • kicad_cleanup_libs (the standard's cleanup_libs): a symbol or footprint library's parts that no given project uses, and parts that repeat another part under a different name (same pins and graphics, or same pads).
    • A plan by default. apply with remove takes out only the names listed, which must be in the plan unless force.
    • A symbol library keeps a .bak; a footprint file moves to .pretty.removed, so both can be put back.
    • A symbol another symbol extends counts as used.
  • kicad_build_symbol_modes (the standard's build_symbol_modes): gives a symbol its De Morgan alternate body, KiCad's second display mode.
    • The alternate drawing (rectangle, polyline, circle, arc) goes into every unit, with the standard body's pins copied and any pin shapes changed (inverted inputs). The symbol is marked as having two body styles.
    • Checked: KiCad plots the alternate as mode 1 (kicad_symbol_mode).
  • kicad_add_symbol_art (the standard's add_symbol_art): draws artwork inside a symbol's body rectangle, scaled to fit with a margin and centred, keeping its aspect.
    • The art is given as polylines, or taken from another symbol's polylines (adom-symbol's baked chip outline).
    • Checked: KiCad plots the art inside the body, with the pins untouched.

Battery 335/0/1.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.90"+version = "1.0.91" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.90"+version = "1.0.91" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.90"+version = "1.0.91" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.90"+version = "1.0.91" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1881 +1,1896 @@⋯ 127 unchanged lines ⋯         # ---- 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={}),⋯ 67 unchanged lines ⋯             "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),⋯ 1477 unchanged lines ⋯         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"]⋯ 187 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- a/rust/crates/kicad-bridge/src/verbs_unsupported.rs+++ b/rust/crates/kicad-bridge/src/verbs_unsupported.rs@@ -1,191 +1,159 @@⋯ 8 unchanged lines ⋯  /// (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)"),⋯ 9 unchanged lines ⋯  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.",         mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,⋯ 126 unchanged lines ⋯         }     } }+--- a/rust/crates/kicad-bridge/src/verbs_libedit.rs+++ b/rust/crates/kicad-bridge/src/verbs_libedit.rs@@ -1,976 +1,1161 @@⋯ 32 unchanged lines ⋯         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.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 120,⋯ 159 unchanged lines ⋯         "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),⋯ 380 unchanged lines ⋯ // 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 {⋯ 384 unchanged lines ⋯     }     out }+--- a/rust/crates/kicad-core/src/symedit.rs+++ b/rust/crates/kicad-core/src/symedit.rs@@ -1,405 +1,614 @@⋯ 7 unchanged lines ⋯ 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"];⋯ 252 unchanged lines ⋯     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)?;⋯ 129 unchanged lines ⋯         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());+    } }+--- a/rust/crates/kicad-core/src/lib.rs+++ b/rust/crates/kicad-core/src/lib.rs@@ -1,74 +1,76 @@⋯ 66 unchanged lines ⋯ // 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; pub mod sch_render; pub mod sch_sheets;+--- a/rust/crates/kicad-core/src/libclean.rs+++ b/rust/crates/kicad-core/src/libclean.rs@@ -0,0 +1,112 @@+//! 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"]));+    }+}+

Comments

No comments yet.

Log in to comment.