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.
← All Pull Requests
kicad-lane: 1.0.95, KiCad on any drive (#137), cross_check from engine 0.24.0, eleven standard verbs by name #57
Merged opened by Colby Knox 2026-10-30
1.0.95: the standard's cross_check from its engine, eleven verbs that were partial under other names, now under their own, and KiCad found on any drive (engine 0.24.0, standard 0.30.0).
- kicad_cross_check: compares the schematic's designators with the board's, computed by the standard's engine.
- The reply gives matched, onSheetNotOnBoard and onBoardNotOnSheet.
- The sheets are read through KiCad's own netlist of the whole hierarchy. The board is read from the open PCB editor when it serves the board, otherwise from the file.
- sameProject (with a _warning) says when the sheet and the board sit in different folders.
- Checked: on a copy of the rp2040 project with C1 deleted from the board, cross_check names C1 as on the sheet but not the board, and the matched count drops by one.
- kicad_list_library: the symbols in a .kicad_sym or the footprints in a .pretty folder, the kind taken from the path (data.names, data.count).
- kicad_open_document, kicad_open_project, kicad_activate_document: open or bring up a board, schematic or project by path in the background. A board opened this way is the one the PCB editor's API serves.
- kicad_render_board_layers: board layers rendered to PNG or SVG through kicad-cli without a window.
- Fourteen presets (copper, silkscreen, mask and paste of either side, outline, drill at real size, assembly, fabrication), or a layer list.
- Bottom views are mirrored so they read from below, and dpi is honoured.
- kicad_export_bom: the project's BOM CSV from its root schematic, with files and outputDir.
- Fixed: KiCad installed on a drive other than C: is found (wiki #137). The bridge searches in this order:
- KiCad's registry key;
- KiCad's uninstall entries;
- Program Files\KiCad, Program Files (x86)\KiCad and \KiCad on every fixed drive;
- kicad-cli on PATH.
- bridge.json's detect globs include D: to G:, so Adom Bridge's host-app check sees such an install too.
- Library paths were already built from the detected root.
- The skill now says never to install KiCad in the Linux workspace: the bridge runs the user's own kicad-cli.
- kicad_routing_drc: KiCad's DRC on the board as the PCB editor holds it (unsaved edits included), with the project's rules and zones refilled. The reply is clean, errors, unconnected and violations[{rule, severity, description, at}].
- kicad_set_track_width: sets the width of the existing tracks and arcs on nets or a net class, optionally on one layer (top, bottom, mid N). An open board takes it as one Undo step through kicad_modify_track; a closed board is written with a backup. The reply gives byNet and drcAfter.
- kicad_footprint_primitives: one placed part's lines, arcs, pads (size, hole, net, solder and paste mask expansion in µm) and texts, in mils on the board, with other kinds counted.
- kicad_rules_by_kind: every rule the board answers to, grouped by kind and sorted by KiCad's precedence: custom .kicad_dru rules, then net classes, then Board Setup minimums.
- Engine adom-eda-checks 0.24.0 vendored unchanged (standard 0.30.0). kicad_bom stays KiCad's own: it was already exact.
Battery 353 pass / 0 fail / 1 skip, no coverage gap, no foreground grabs. Fixes #137.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,533 +1,536 @@⋯ 31 unchanged lines ⋯ never started: keep polling, never resend a mutation blindly. `plugins[]` on the same reply is plugin liveness, not progress. +**KiCad lives on the user's PC, never in this workspace.** Don't `apt install kicad` (or pip/conda it) in the Linux workspace to run DRC, ERC or an export: the bridge runs the user's own KiCad, `kicad-cli` included, through `kicad_run_drc`, `kicad_run_erc`, `kicad_export_*` and the other headless verbs. A KiCad installed on any drive (D:, E:, ...) is found: the registry, KiCad's uninstall entries, every fixed drive's `Program Files\KiCad` and `\KiCad`, then `kicad-cli` on PATH (from 1.0.95, wiki #137). `kicad_readiness` says which install it found and where.+ **Readiness before you drive:** - `kicad_readiness '{}'`: read-only, zero side effects: is KiCad installed/ready? (`{detected, ready, versionCount, defaultVersion}`.) There's **no prewarm**: KiCad is a host app you install on request via `kicad_upgrade`, not a downloaded runtime asset. - `kicad_describe '{}'`: the full machine-readable verb catalog (the completeness escape hatch; the per-call `_hint`s are what carry you in practice).⋯ 493 unchanged lines ⋯ - Never quote a wiki page you have not fetched: `aravk/in-s42ata`, an older example, no longer exists. - **kicad-bridge-contributing**: you hit a bug or a missing verb: file it with the verbatim reply, disclose any workaround, and how to clone this page's repo, build, test and send a branch PR that gets merged within minutes.+@@ -1,455 +1,475 @@⋯ 345 unchanged lines ⋯ "kicad_run_script", "kicad_eval", "kicad_schematic_review",+ "kicad_cross_check",+ "kicad_list_library",+ "kicad_open_document",+ "kicad_open_project",+ "kicad_activate_document",+ "kicad_render_board_layers",+ "kicad_export_bom",+ "kicad_routing_drc",+ "kicad_set_track_width",+ "kicad_footprint_primitives",+ "kicad_rules_by_kind", "kicad_review_board" ], "statusVerb": "kicad_status",⋯ 43 unchanged lines ⋯ "windows": [ "%ProgramFiles%\\KiCad\\*\\bin\\kicad.exe", "%ProgramFiles(x86)%\\KiCad\\*\\bin\\kicad.exe",- "%LocalAppData%\\Programs\\KiCad\\*\\bin\\kicad.exe"+ "%LocalAppData%\\Programs\\KiCad\\*\\bin\\kicad.exe",+ "D:\\Program Files\\KiCad\\*\\bin\\kicad.exe",+ "D:\\KiCad\\*\\bin\\kicad.exe",+ "E:\\Program Files\\KiCad\\*\\bin\\kicad.exe",+ "E:\\KiCad\\*\\bin\\kicad.exe",+ "F:\\Program Files\\KiCad\\*\\bin\\kicad.exe",+ "F:\\KiCad\\*\\bin\\kicad.exe",+ "G:\\Program Files\\KiCad\\*\\bin\\kicad.exe",+ "G:\\KiCad\\*\\bin\\kicad.exe" ], "macos": [ "/Applications/KiCad/KiCad.app"⋯ 51 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-eda-checks"-version = "0.23.0"+version = "0.24.0" dependencies = [ "roxmltree", "serde_json",⋯ 302 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.94"+version = "1.0.95" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.94"+version = "1.0.95" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.94"+version = "1.0.95" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.94"+version = "1.0.95" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,62 +1,64 @@⋯ 48 unchanged lines ⋯ Group { name: "app", verbs: || crate::verbs_app::VERBS, dispatch: crate::verbs_app::dispatch }, Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch }, Group { name: "interop", verbs: || crate::verbs_interop::VERBS, dispatch: crate::verbs_interop::dispatch },+ Group { name: "std_more", verbs: || crate::verbs_std_more::VERBS, dispatch: crate::verbs_std_more::dispatch }, Group { name: "unsupported", verbs: || crate::verbs_unsupported::VERBS, dispatch: crate::verbs_unsupported::dispatch }, ]; ⋯ 5 unchanged lines ⋯ } None }+@@ -1,291 +1,293 @@⋯ 44 unchanged lines ⋯ mod verbs_sim; mod verbs_app; mod verbs_interop;+mod verbs_std_more; mod verbs_unsupported; mod verbs_build; mod verbs_windows;⋯ 238 unchanged lines ⋯ } } }+@@ -1,965 +1,966 @@⋯ 346 unchanged lines ⋯ } /// The closed-file path: load (refused while KiCad holds the lock), build, write, read back.-fn file_edit(args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>) -> Value {+pub(crate) fn file_edit(args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>) -> Value { let dry = args.get("dryRun") == Some(&json!(true)); let loaded = match pcb::load(args, dry) { Ok(l) => l,⋯ 609 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -1,391 +1,443 @@⋯ 22 unchanged lines ⋯ pitfalls: &["Pin types come from the symbols: a passive pin is never 'undriven'.", "Reads the saved schematic (kicad-cli): save it in KiCad first."], }, Verb {+ name: "kicad_cross_check",+ summary: "Compare the schematic's designators with the board's (the standard's cross_check, computed by its engine): parts on the sheets but not on the board, and parts on the board but not on any sheet.",+ mechanism: Mechanism::Cli, risk: "read", timeout_sec: 120,+ input: "{\"sheet\" | \"filePath\": \"C:/.../x.kicad_sch\" (the root sheet: the whole hierarchy), \"board\" | \"boardPath\": \"C:/.../x.kicad_pcb\", \"projectPath\"?: \"C:/.../x.kicad_pro\" (both beside it), \"source\"?: \"auto\"|\"live\"|\"file\" (the board)}",+ example: "kicad_cross_check {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+ hint: "matched, onSheetNotOnBoard (update the board: kicad_update_pcb_from_schematic) and onBoardNotOnSheet (parts placed by hand or left after a schematic change). The sheets are read through KiCad's own netlist of the saved schematic, every sheet of the hierarchy; the board from the open PCB editor when it serves it, else the file. sameProject is false when the two are in different folders: the differences then mean little.",+ related: &["kicad_update_pcb_from_schematic", "kicad_analyze_connections", "kicad_schematic_review", "kicad_bom"],+ pitfalls: &["Reads the saved schematic (kicad-cli): save it in KiCad first.", "Parts kept out of the BOM by kind (mounting holes, fiducials) are not counted as missing from the board."],+ },+ Verb { name: "kicad_extract_netlist", summary: "Extract the full netlist from a schematic: every component and every net with its nodes (read-only, via kicad-cli).", mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,⋯ 49 unchanged lines ⋯ Some(match command { "kicad_extract_netlist" => extract_netlist(state, args), "kicad_schematic_review" => schematic_review(state, args),+ "kicad_cross_check" => cross_check(state, args), "kicad_netlist" => standard_netlist(state, args), "kicad_trace_net" => trace_net(state, args), "kicad_find_connections" => find_connections(state, args),⋯ 257 unchanged lines ⋯ /// kicad_schematic_review: KiCad's netlist of the design as the standard's sheetParts and netlist /// (netlist::review_inputs), reviewed by the shared engine.+/// `kicad_cross_check`: the standard's cross_check from its engine, fed the sheets' parts (KiCad's netlist of the whole+/// hierarchy) and the standard board model.+fn cross_check(state: &mut State, args: &Value) -> Value {+ let pro = arg_str(args, "projectPath").map(PathBuf::from);+ let sheet = arg_any(args, &["sheet", "filePath", "schematicPath"]).map(PathBuf::from)+ .or_else(|| pro.as_ref().map(|p| p.with_extension("kicad_sch")))+ .or_else(|| arg_any(args, &["board", "boardPath"]).map(|b| PathBuf::from(b).with_extension("kicad_sch")));+ let board = arg_any(args, &["board", "boardPath"]).map(PathBuf::from)+ .or_else(|| pro.as_ref().map(|p| p.with_extension("kicad_pcb")))+ .or_else(|| sheet.as_ref().map(|s| s.with_extension("kicad_pcb")));+ let (Some(sheet), Some(board)) = (sheet, board) else {+ return fail("missing_arg", "projectPath, or sheet and board, is required", "Pass the project (its root .kicad_sch and .kicad_pcb sit beside the .kicad_pro), or both paths.");+ };+ let (model, _) = match model_for(state, &json!({"filePath": norm(&sheet)})) {+ Ok(x) => x,+ Err(e) => return e,+ };+ let (parts, _) = netlist::review_inputs(&model);+ let r = match crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": arg_str(args, "source").unwrap_or("auto")})) {+ Ok(r) => r,+ Err(e) => return e,+ };+ let built = match kicad_core::board_model::build(&r.text, Some(&r.file), &kicad_core::board_model::Opts { y_up: true }) {+ Ok(b) => b,+ Err(e) => return fail("board_unparsable", e, "A damaged board opens and saves in KiCad to repair it."),+ };+ let mut v = adom_eda_checks::run("cross_check", &json!({"sheetParts": parts, "board": built}));+ if v["success"] == json!(true) {+ v["sheet"] = json!(norm(&sheet));+ v["board"] = json!(norm(&board));+ v["sameProject"] = json!(sheet.parent() == board.parent());+ v["boardSource"] = json!(if r.live { "live-editor" } else { "file" });+ if sheet.parent() != board.parent() {+ v["_warning"] = json!("The sheet and the board are in different folders, probably different projects: every difference may be meaningless.");+ }+ }+ v+}+ fn schematic_review(state: &mut State, args: &Value) -> Value { let mut a = args.clone(); if arg_str(args, "filePath").is_none() {⋯ 42 unchanged lines ⋯ } v }++@@ -0,0 +1,505 @@+//! Standard verbs KiCad answered only partly under other names (tools/equivalents/kicad.json of the EDA Bridge+//! Standard), now under their own names with the standard's arguments and replies: list_library, open_document,+//! open_project, activate_document, render_board_layers, export_bom, routing_drc. Each is a thin shape over KiCad verbs the bridge+//! already has; the ones that open windows call them through verbs::call, so these are never Window verbs themselves+//! (a Window verb calling another through dispatch would wait on the GUI lock it holds).++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_list_library",+ summary: "Names of every symbol in a .kicad_sym or every footprint in a .pretty folder, the kind taken from the path (the standard's list_library).",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+ input: "{\"libraryPath\" | \"filePath\": \"C:/.../Lib.kicad_sym\" | \"C:/.../Lib.pretty\", \"limit\"?: 500}",+ example: "kicad_list_library {\"libraryPath\":\"C:/p/MyParts.kicad_sym\"}",+ hint: "data.names in file order; kind sch for a symbol library, pcb for a footprint library. kicad_list_symbols and kicad_list_footprints search the registered libraries by name instead.",+ related: &["kicad_list_symbols", "kicad_list_footprints", "kicad_sym_info", "kicad_fp_info"],+ pitfalls: &["A .pretty is a folder: its footprints are the .kicad_mod files in it."],+ },+ Verb {+ name: "kicad_open_document",+ summary: "Open a board, schematic or project by path in KiCad without taking the foreground (the standard's open_document): .kicad_pcb in the PCB editor, .kicad_sch in the Schematic Editor, .kicad_pro in the project manager.",+ mechanism: Mechanism::File, risk: "process", timeout_sec: 130,+ input: "{\"filePath\" | \"path\": \"C:/.../x.kicad_pcb\" | \".kicad_sch\" | \".kicad_pro\"}",+ example: "kicad_open_document {\"filePath\":\"C:/p/board.kicad_pcb\"}",+ hint: "data says what was opened (objectKind Board, Schematic or Project) and the reply carries the KiCad verb's own answer. A board opened here is the one the PCB editor's API serves, so the live verbs act on it.",+ related: &["kicad_open_board", "kicad_open_schematic", "kicad_launch", "kicad_close"],+ pitfalls: &["KiCad holds one project at a time: opening another project's board replaces the open one."],+ },+ Verb {+ name: "kicad_open_project",+ summary: "Open a KiCad project (.kicad_pro) in the project manager without taking the foreground (the standard's open_project).",+ mechanism: Mechanism::File, risk: "process", timeout_sec: 130,+ input: "{\"filePath\" | \"path\": \"C:/.../x.kicad_pro\"}",+ example: "kicad_open_project {\"filePath\":\"C:/p/board.kicad_pro\"}",+ hint: "Then kicad_open_document opens its board or schematic.",+ related: &["kicad_open_document", "kicad_launch"],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_activate_document",+ summary: "Make a board or schematic the current one, in the background (the standard's activate_document): KiCad's verbs take the file's path, and a board opened here is the one the PCB editor's API serves.",+ mechanism: Mechanism::File, risk: "process", timeout_sec: 130,+ input: "{\"path\" | \"document\" | \"filePath\": \"C:/.../x.kicad_pcb\" | \".kicad_sch\"}",+ example: "kicad_activate_document {\"path\":\"C:/p/board.kicad_pcb\"}",+ hint: "An open document is brought up, not reloaded; one that is not open is opened.",+ related: &["kicad_open_document", "kicad_open_board"],+ pitfalls: &["KiCad holds one board and one schematic per project: activating another project's board replaces the open one."],+ },+ Verb {+ name: "kicad_render_board_layers",+ summary: "Render chosen board layers to a PNG or SVG through kicad-cli without opening a window (the standard's render_board_layers): a preset view (copper, silkscreen, solder mask, paste, outline, drill, assembly, fabrication, either side) or a list of layers.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 240,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"preset\"?: \"all\"|\"top_copper\"|\"bottom_copper\"|\"top_silkscreen\"|\"bottom_silkscreen\"|\"top_soldermask\"|\"bottom_soldermask\"|\"top_paste\"|\"bottom_paste\"|\"outline\"|\"drill\"|\"assembly_top\"|\"assembly_bottom\"|\"fabrication\", \"layers\"?: [\"F.Cu\", ...] (overrides preset), \"format\"?: \"png\"|\"svg\", \"dpi\"?: 300, \"mirror\"?: bool, \"outputPath\"?: \"C:/out/top.png\", \"listPresets\"?: bool}",+ example: "kicad_render_board_layers {\"filePath\":\"C:/p/b.kicad_pcb\",\"preset\":\"top_copper\"}",+ hint: "The picture is cropped to the board. Bottom presets are mirrored, so they read as seen from below (mirror overrides). drill draws the holes at their real size over the outline. kicad_render_board is the 3D view instead.",+ related: &["kicad_export_svg", "kicad_render_board", "kicad_export_gerber"],+ pitfalls: &["Reads the saved board file: save the open board first."],+ },+ Verb {+ name: "kicad_export_bom",+ summary: "Export the project's bill of materials as a CSV through kicad-cli from its root schematic (the standard's export_bom).",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\": \"C:/.../x.kicad_sch\", \"outputDir\"?: \"C:/...\"}",+ example: "kicad_export_bom {\"projectPath\":\"C:/p/board.kicad_pro\"}",+ hint: "files lists what was written in outputDir. kicad_bom groups by part number and writes JLCPCB, Mouser and DigiKey files instead.",+ related: &["kicad_bom", "kicad_export_bom_csv", "kicad_export_pos"],+ pitfalls: &["Reads the saved schematic: save it in KiCad first."],+ },+ Verb {+ name: "kicad_routing_drc",+ summary: "KiCad's DRC on the board as the PCB editor holds it (unsaved edits included; the file when no editor serves it), with the project's rules and zones refilled: one clean or not-clean answer with every violation's place (the standard's routing_drc).",+ mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"source\"?: \"auto\"|\"live\"|\"file\", \"full\"?: true}",+ example: "kicad_routing_drc {\"filePath\":\"C:/p/b.kicad_pcb\"}",+ hint: "clean is true with no violations and nothing unrouted. violations[] are {rule (KiCad's check), severity, description, at {x, y} mm in KiCad's frame}; unconnected counts the ratsnest. KiCad's DRC is always a full one: full is accepted and changes nothing.",+ related: &["kicad_run_drc", "kicad_routing_validate", "kicad_routing_state"],+ pitfalls: &["Warnings count as violations here, as KiCad lists them; severity tells them apart."],+ },+ Verb {+ name: "kicad_set_track_width",+ summary: "Set the width of the EXISTING tracks and arcs on nets or a net class, optionally on one layer (the standard's set_track_width): widen power nets after routing, or thin a net that fails clearance. Net-class rules only steer new routing.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"VBUS\"] | \"netClass\"?: \"Power\", \"widthMm\": 0.5, \"layer\"?: \"top\"|\"bottom\"|\"mid 1\"|\"F.Cu\", \"source\"?: \"auto\"|\"live\"|\"file\", \"dryRun\"?: bool}",+ example: "kicad_set_track_width {\"filePath\":\"C:/p/b.kicad_pcb\",\"nets\":[\"VBUS\"],\"widthMm\":0.6}",+ hint: "byNet counts the tracks each net has (changed is how many took a new width); drcAfter is KiCad's DRC on the board afterwards {errors, unconnected}. On a board open in the PCB editor it is one Undo step through kicad_modify_track (track ids, ends and nets kept); a closed board is written with a .adom-bak.",+ related: &["kicad_modify_track", "kicad_netclasses", "kicad_routing_drc", "kicad_current_capacity"],+ pitfalls: &["A wider track can break clearance: read drcAfter, and narrow it again or reroute where it does.", "netClass uses the project's class patterns (.kicad_pro beside the board)."],+ },+ Verb {+ name: "kicad_footprint_primitives",+ summary: "Geometry of one PLACED part on the board (the standard's footprint_primitives): its graphic lines and arcs, pads (shape, size, hole, net, solder and paste mask expansion in um) and texts, in mils in KiCad's board frame with each item's layer; other kinds are counted.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"designator\": \"U1\", \"source\"?: \"auto\"|\"live\"|\"file\"}",+ example: "kicad_footprint_primitives {\"filePath\":\"C:/p/b.kicad_pcb\",\"designator\":\"U1\"}",+ hint: "data.primitives[] are {kind: track|arc|pad|text, layerId (KiCad's layer name), ...} placed on the board (the part's position and rotation applied); otherPrimitives counts polygons, circles, rectangles and models. Mask expansions are the pad's own, else the footprint's; null means the board's default.",+ related: &["kicad_footprint_graphics", "kicad_component_pins", "kicad_routing_state"],+ pitfalls: &["mils, y down (KiCad's frame): kicad_routing_state {model:\"standard\"} has the board in mm, y up."],+ },+ Verb {+ name: "kicad_rules_by_kind",+ summary: "Every design rule the board answers to, grouped by kind (Clearance, Width, RoutingVias, HoleSize, ...) and sorted by priority (1 wins), from the project's custom rules (.kicad_dru), net classes and Board Setup minimums (the standard's rules_by_kind).",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\" | \"projectPath\": \"C:/.../x.kicad_pro\", \"limit\"?: 2000}",+ example: "kicad_rules_by_kind {\"projectPath\":\"C:/p/board.kicad_pro\"}",+ hint: "rulesByKind.{Kind}[] are {name, kind, constraint (KiCad's), enabled, priority, scope1 (the condition, NetClass == '...' or All), scope2, min/opt/max, source: custom|netclass|board}. KiCad's precedence orders them: a later custom rule over an earlier one, custom rules over net classes, both over Board Setup. kicad_custom_rules and kicad_netclasses edit them.",+ related: &["kicad_board_rules", "kicad_netclasses", "kicad_custom_rules", "kicad_run_drc"],+ pitfalls: &["Reads the saved project files: rules changed in an open Board Setup dialog count once saved."],+ },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_list_library" => list_library(args),+ "kicad_open_document" => open_doc(state, args, "open_document"),+ "kicad_open_project" => open_doc(state, args, "open_project"),+ "kicad_activate_document" => open_doc(state, args, "activate_document"),+ "kicad_render_board_layers" => render_layers(state, args),+ "kicad_export_bom" => export_bom(state, args),+ "kicad_routing_drc" => routing_drc(state, args),+ "kicad_set_track_width" => set_track_width(state, args),+ "kicad_footprint_primitives" => footprint_primitives(state, args),+ "kicad_rules_by_kind" => rules_by_kind(args),+ _ => return None,+ })+}++fn ext(p: &Path) -> String {+ p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()+}++fn list_library(args: &Value) -> Value {+ let Some(path) = arg_any(args, &["libraryPath", "filePath", "path"]).map(|p| PathBuf::from(p.trim_end_matches(['/', '\\']))) else {+ return fail("missing_arg", "libraryPath is required", "A .kicad_sym file or a .pretty folder.");+ };+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;+ let (kind, mut names): (&str, Vec<String>) = match ext(&path).as_str() {+ "kicad_sym" => {+ let text = match std::fs::read_to_string(&path) {+ Ok(t) => t,+ Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .kicad_sym's absolute path."),+ };+ match kicad_core::sxedit::parse(&text) {+ Ok(root) => ("sch", root.kids_tagged("symbol").filter_map(|s| s.atom(1).map(str::to_string)).collect()),+ Err(e) => return fail("library_unparsable", format!("{} does not parse: {e}", norm(&path)), "Open and save it in KiCad's Symbol Editor to repair it."),+ }+ }+ "pretty" => match std::fs::read_dir(&path) {+ Ok(rd) => ("pcb", rd.flatten().map(|e| e.path()).filter(|p| ext(p) == "kicad_mod").filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())).collect()),+ Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .pretty folder's absolute path."),+ },+ _ => return fail("bad_args", format!("{} is not a .kicad_sym or a .pretty folder", norm(&path)), "Symbol libraries are .kicad_sym files; footprint libraries are .pretty folders."),+ };+ if kind == "pcb" {+ names.sort();+ }+ let count = names.len();+ names.truncate(limit);+ json!({"success": true, "kind": kind, "path": norm(&path), "data": {"kind": kind, "count": count, "names": names}, "truncated": count > limit})+}++fn open_doc(state: &mut State, args: &Value, which: &str) -> Value {+ let Some(path) = arg_any(args, &["filePath", "path", "document", "name"]).map(PathBuf::from) else {+ return fail("missing_arg", "filePath (or path) is required", "The absolute path of a .kicad_pcb, .kicad_sch or .kicad_pro.");+ };+ if !path.is_file() {+ return fail("file_not_found", format!("not found: {}", norm(&path)), "Use the desktop's absolute path.");+ }+ let p = norm(&path);+ let (verb, a, object, kind) = match ext(&path).as_str() {+ "kicad_pcb" if which != "open_project" => ("kicad_open_board", json!({"filePath": p, "foreground": false}), "Board", "pcb"),+ "kicad_sch" if which != "open_project" => ("kicad_open_schematic", json!({"filePath": p, "foreground": false}), "Schematic", "sch"),+ "kicad_pro" if which != "activate_document" => ("kicad_launch", json!({"project": p}), "Project", "project"),+ e => {+ let want = match which { "open_project" => "a .kicad_pro", "activate_document" => "a .kicad_pcb or .kicad_sch", _ => "a .kicad_pcb, .kicad_sch or .kicad_pro" };+ return fail("bad_args", format!("{which} takes {want}, not .{e}"), "Library files open in the Symbol and Footprint Editors (kicad_open_symbol_editor, kicad_open_footprint_editor).");+ }+ };+ let mut out = crate::verbs::call(state, verb, &a);+ if out["success"] == json!(true) {+ out["data"] = json!({"path": p, "objectKind": object, "kind": kind, "shown": true, "lane": verb});+ out["windowEtiquette"] = json!("background");+ }+ out+}++/// (preset, layers, mirrored, drill marks at their real size, what it shows)+const PRESETS: &[(&str, &[&str], bool, bool, &str)] = &[+ ("all", &["F.Cu", "B.Cu", "F.Silkscreen", "B.Silkscreen", "F.Mask", "B.Mask", "Edge.Cuts"], false, false, "copper, silkscreen and mask of both sides with the outline"),+ ("top_copper", &["F.Cu", "Edge.Cuts"], false, false, "top copper and the outline"),+ ("bottom_copper", &["B.Cu", "Edge.Cuts"], true, false, "bottom copper, seen from below"),+ ("top_silkscreen", &["F.Silkscreen", "Edge.Cuts"], false, false, "top silkscreen and the outline"),+ ("bottom_silkscreen", &["B.Silkscreen", "Edge.Cuts"], true, false, "bottom silkscreen, seen from below"),+ ("top_soldermask", &["F.Mask", "Edge.Cuts"], false, false, "top solder mask openings"),+ ("bottom_soldermask", &["B.Mask", "Edge.Cuts"], true, false, "bottom solder mask openings, seen from below"),+ ("top_paste", &["F.Paste", "Edge.Cuts"], false, false, "top paste (stencil) openings"),+ ("bottom_paste", &["B.Paste", "Edge.Cuts"], true, false, "bottom paste openings, seen from below"),+ ("outline", &["Edge.Cuts"], false, false, "the board outline"),+ ("drill", &["Edge.Cuts"], false, true, "every hole at its real size over the outline"),+ ("assembly_top", &["F.Fab", "Edge.Cuts"], false, false, "top fabrication layer (part outlines and references)"),+ ("assembly_bottom", &["B.Fab", "Edge.Cuts"], true, false, "bottom fabrication layer, seen from below"),+ ("fabrication", &["Edge.Cuts", "User.Drawings", "User.Comments"], false, true, "outline, drawings and holes for the fab"),+];++fn render_layers(state: &mut State, args: &Value) -> Value {+ let presets: serde_json::Map<String, Value> = PRESETS.iter().map(|p| (p.0.to_string(), json!(p.4))).collect();+ if args.get("listPresets") == Some(&json!(true)) {+ return json!({"success": true, "presets": presets});+ }+ let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {+ return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");+ };+ if !board.is_file() {+ return fail("file_not_found", format!("board not found: {}", norm(&board)), "Use the desktop's absolute path.");+ }+ let preset_name = arg_str(args, "preset").unwrap_or("all");+ let Some(preset) = PRESETS.iter().find(|p| p.0 == preset_name) else {+ let mut v = fail("bad_args", format!("no preset {preset_name}"), "listPresets:true lists them.");+ v["presets"] = json!(presets);+ return v;+ };+ let given: Vec<String> = match args.get("layers") {+ Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+ Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+ _ => Vec::new(),+ };+ let custom = !given.is_empty();+ let layers: Vec<String> = if custom { given } else { preset.1.iter().map(|s| s.to_string()).collect() };+ let mirror = args.get("mirror").and_then(Value::as_bool).unwrap_or(!custom && preset.2);+ let format = arg_str(args, "format").unwrap_or("png").to_ascii_lowercase();+ if format != "png" && format != "svg" {+ return fail("bad_args", format!("format must be png or svg, not {format}"), "png is the default.");+ }+ let dpi = args.get("dpi").and_then(Value::as_f64).unwrap_or(300.0).clamp(30.0, 2400.0);+ let info = state.kicad_info();+ let Some(cli) = info.primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()) else {+ return fail("not_installed", "kicad-cli was not found", "Install KiCad 10 (kicad_upgrade).");+ };+ let stem = board.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "board".into());+ let label = if custom { "custom".to_string() } else { preset_name.to_string() };+ let out = arg_any(args, &["outputPath", "output"]).map(PathBuf::from)+ .unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join("layers_output").join(format!("{stem}-{label}.{format}")));+ if let Some(d) = out.parent() {+ if let Err(e) = std::fs::create_dir_all(d) {+ return fail("output_dir", format!("cannot create {}: {e}", norm(d)), "Pass an outputPath the desktop user can write.");+ }+ }+ let svg = if format == "svg" { out.clone() } else { out.with_extension("svg") };+ let (svg_s, board_s, layer_arg) = (norm(&svg), norm(&board), layers.join(","));+ let mut argv = vec!["pcb", "export", "svg", "--mode-single", "--page-size-mode", "2", "--exclude-drawing-sheet", "--layers", layer_arg.as_str(), "--output", svg_s.as_str()];+ if mirror {+ argv.push("--mirror");+ }+ if !custom && preset.3 {+ argv.extend(["--drill-shape-opt", "2"]);+ }+ argv.push(board_s.as_str());+ let r = kicad_core::cli::run(&cli, &argv, Duration::from_secs(200));+ if r.exit_code != 0 || !svg.is_file() {+ let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("kicad-cli could not plot the layers: {}", r.stderr.trim()), "A layer name KiCad does not know is the usual cause: F.Cu, In1.Cu, B.Cu, F.Silkscreen, F.Mask, F.Paste, F.Fab, Edge.Cuts, User.Drawings ...");+ v["layers"] = json!(layers);+ return v;+ }+ let mut reply = json!({"success": true, "format": format, "preset": label, "layers": layers, "mirrored": mirror, "board": board_s, "presets": presets, "cliElapsedMs": r.elapsed_ms});+ if format == "svg" {+ reply["path"] = json!(svg_s);+ return reply;+ }+ let bytes = match std::fs::read(&svg) {+ Ok(b) => b,+ Err(e) => return fail("read_failed", format!("cannot read {svg_s}: {e}"), "Try again; the plot was written a moment ago."),+ };+ // the plot's size in mm, then the width that gives the dpi asked for+ let probe = match kicad_core::sch_render::rasterize(&bytes, 64) {+ Ok(p) => p,+ Err(e) => return fail("render_failed", e, "format:\"svg\" writes the plot without rasterising it."),+ };+ let width = ((probe.width_mm / 25.4 * dpi).round() as u32).clamp(16, 16384);+ let raster = match kicad_core::sch_render::rasterize(&bytes, width) {+ Ok(p) => p,+ Err(e) => return fail("render_failed", e, "Pass a lower dpi, or format:\"svg\"."),+ };+ if let Err(e) = kicad_core::sch_render::save_png(&raster.pixmap, &out) {+ return fail("write_failed", e, "Pass an outputPath the desktop user can write.");+ }+ let _ = std::fs::remove_file(&svg);+ reply["path"] = json!(norm(&out));+ reply["widthPx"] = json!(raster.pixmap.width());+ reply["heightPx"] = json!(raster.pixmap.height());+ reply["dpi"] = json!(dpi);+ reply+}++fn export_bom(state: &mut State, args: &Value) -> Value {+ let sch = match (arg_str(args, "projectPath"), arg_any(args, &["filePath", "schematicPath"])) {+ (_, Some(f)) => PathBuf::from(f),+ (Some(p), None) => PathBuf::from(p).with_extension("kicad_sch"),+ _ => return fail("missing_arg", "projectPath or filePath (the root .kicad_sch) is required", "The project; its root sheet sits beside the .kicad_pro."),+ };+ let started = std::time::Instant::now();+ let mut a = json!({"filePath": norm(&sch)});+ if let Some(d) = arg_str(args, "outputDir") {+ a["outputDir"] = json!(d);+ }+ let mut out = crate::verbs::call(state, "kicad_export_bom_csv", &a);+ if out["success"] == json!(true) {+ out["files"] = out.pointer("/data/files").cloned().unwrap_or(json!([]));+ out["outputDir"] = out.pointer("/data/output_path").and_then(Value::as_str).map(|p| json!(norm(Path::new(p).parent().unwrap_or(Path::new(p))))).unwrap_or(Value::Null);+ out["surface"] = json!("bom");+ out["outjob"] = Value::Null;+ out["dialogs"] = json!([]);+ out["elapsedSec"] = json!(started.elapsed().as_secs_f64());+ }+ out+}++fn routing_drc(state: &mut State, args: &Value) -> Value {+ let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {+ return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");+ };+ let r = match crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": arg_str(args, "source").unwrap_or("auto")})) {+ Ok(r) => r,+ Err(e) => return e,+ };+ let cli = state.kicad_info().primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+ let rep = match drc_of(cli.as_deref(), &board, &r.text) {+ Ok(v) => v,+ Err(e) => return e,+ };+ let at = |v: &Value| v.pointer("/items/0/pos").map(|p| json!({"x": p.get("x"), "y": p.get("y")})).unwrap_or(Value::Null);+ let violations: Vec<Value> = rep["violations"].as_array().cloned().unwrap_or_default().iter()+ .map(|v| json!({"rule": v.get("type"), "severity": v.get("severity"), "description": v.get("description"), "at": at(v)}))+ .collect();+ let unconnected = rep["unconnected"].as_u64().unwrap_or(0);+ let errors = violations.iter().filter(|v| v["severity"] == json!("error")).count();+ json!({+ "success": true, "clean": violations.is_empty() && unconnected == 0, "errors": errors, "warnings": violations.len() - errors,+ "unconnected": unconnected, "violations": violations, "full": true, "boardSource": if r.live { "live-editor" } else { "file" }, "board": norm(&board),+ "projectRulesCopied": rep.get("projectRulesCopied"),+ "_hint": "Positions are mm in KiCad's frame (y down). kicad_routing_state {model:\"standard\"} gives the same board in the standard's y-up frame.",+ })+}++fn rules_by_kind(args: &Value) -> Value {+ let Some(pro) = arg_any(args, &["projectPath", "filePath", "boardPath"]).map(|p| PathBuf::from(p).with_extension("kicad_pro")) else {+ return fail("missing_arg", "projectPath or filePath is required", "The project, or its board: the .kicad_pro and .kicad_dru sit beside it.");+ };+ let pv: Value = match std::fs::read_to_string(&pro) {+ Ok(t) => serde_json::from_str(&t).unwrap_or(Value::Null),+ Err(e) => return fail("project_not_found", format!("cannot read {}: {e}", norm(&pro)), "Pass the board or project path; its .kicad_pro must exist."),+ };+ let dru = std::fs::read_to_string(pro.with_extension("kicad_dru")).ok();+ let mut v = kicad_core::project_rules::rules_by_kind(&pv, dru.as_deref());+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(2000) as usize;+ if let Some(m) = v["rulesByKind"].as_object_mut() {+ for list in m.values_mut() {+ if let Some(a) = list.as_array_mut() {+ a.truncate(limit);+ }+ }+ }+ v["success"] = json!(true);+ v["board"] = json!(norm(&pro.with_extension("kicad_pcb")));+ v["customRules"] = json!(dru.is_some());+ v+}++fn footprint_primitives(state: &mut State, args: &Value) -> Value {+ let Some(d) = arg_any(args, &["designator", "ref", "reference"]) else {+ return fail("missing_arg", "designator is required", "A part on the board, e.g. U1.");+ };+ let r = match crate::verbs_standard::read(state, args) {+ Ok(r) => r,+ Err(e) => return e,+ };+ match kicad_core::board_edit::footprint_primitives(&r.text, d) {+ Ok(data) => json!({"success": true, "data": data, "boardSource": if r.live { "live-editor" } else { "file" }, "file": r.file}),+ Err(e) => fail(&e.code, e.message.clone(), "kicad_list_designators lists the parts on the board."),+ }+}++fn set_track_width(state: &mut State, args: &Value) -> Value {+ use kicad_core::board_edit;+ let Some(board) = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from) else {+ return fail("missing_arg", "filePath is required", "The board's absolute .kicad_pcb path.");+ };+ let Some(width) = args.get("widthMm").or_else(|| args.get("width")).and_then(Value::as_f64) else {+ return fail("missing_arg", "widthMm is required", "The new track width in mm, e.g. 0.5.");+ };+ let layer = arg_str(args, "layer").map(board_edit::track_layer);+ let source = arg_str(args, "source").unwrap_or("auto");+ let r = match crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": source})) {+ Ok(r) => r,+ Err(e) => return e,+ };+ let mut nets: Vec<String> = match args.get("nets") {+ Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+ Some(Value::String(s)) => vec![s.clone()],+ _ => Vec::new(),+ };+ if let Some(class) = arg_str(args, "netClass") {+ let pro: Value = std::fs::read_to_string(board.with_extension("kicad_pro")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+ let all = kicad_core::sxedit::parse(&r.text).map(|root| board_edit::board_net_names(&root)).unwrap_or_default();+ nets.extend(all.into_iter().filter(|n| !n.is_empty() && kicad_core::project_rules::class_for_net(&pro, n)["name"] == json!(class)));+ }+ nets.sort();+ nets.dedup();+ if nets.is_empty() {+ return fail("missing_arg", "nets or netClass is required, and must name nets on the board", "kicad_list_nets lists the nets; kicad_netclasses the classes and their nets.");+ }+ let hits = match board_edit::tracks_on(&r.text, &nets, layer.as_deref()) {+ Ok(h) => h,+ Err(e) => return crate::util::fail(&e.code, e.message.clone(), "The board does not parse; open and save it in KiCad."),+ };+ let mut by_net = serde_json::Map::new();+ for (_, n, _) in &hits {+ let c = by_net.get(n).and_then(Value::as_u64).unwrap_or(0) + 1;+ by_net.insert(n.clone(), json!(c));+ }+ let ids: Vec<String> = hits.iter().filter(|(_, _, w)| (w - width).abs() >= 1e-9).map(|(i, _, _)| i.clone()).collect();+ let dry = args.get("dryRun") == Some(&json!(true));+ let mut out = if r.live {+ if ids.is_empty() || dry {+ json!({"success": true, "source": "live-editor", "mutated": false, "dryRun": dry, "wouldChange": ids.len()})+ } else {+ let rs = crate::verbs::call(state, "kicad_routing_state", &json!({"filePath": norm(&board)}));+ let m = crate::verbs::call(state, "kicad_modify_track", &json!({"filePath": norm(&board), "expectedRevision": rs.get("revision"), "itemIds": ids, "width": width}));+ if m["success"] != json!(true) {+ return m;+ }+ let mut o = json!({"success": true, "source": "live-editor", "mutated": true, "undoSteps": 1});+ for k in ["revision", "verified", "drc"] {+ if let Some(v) = m.get(k) {+ o[k] = v.clone();+ }+ }+ o+ }+ } else {+ crate::verbs_board::file_edit(args, &|t| board_edit::set_track_width(t, &nets, layer.as_deref(), width))+ };+ if out["success"] != json!(true) {+ return out;+ }+ out["changed"] = json!(if dry || out["mutated"] != json!(true) { 0 } else { ids.len() });+ out["byNet"] = json!(by_net);+ out["nets"] = json!(nets);+ out["widthMm"] = json!(width);+ out["layer"] = json!(layer);+ // KiCad's DRC on the board as it is now+ let after = crate::verbs_standard::read(state, &json!({"filePath": norm(&board), "source": if r.live { "live" } else { "file" }})).ok();+ let cli = state.kicad_info().primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+ if let Some(a) = after {+ match drc_of(cli.as_deref(), &board, &a.text) {+ Ok(d) => out["drcAfter"] = json!({"errors": d["errors"], "unconnected": d["unconnected"], "clean": d["clean"]}),+ Err(e) => out["drcAfter"] = json!({"error": e["error"]}),+ }+ }+ out+}++#[cfg(feature = "ipc")]+fn drc_of(cli: Option<&Path>, board: &Path, text: &str) -> Result<Value, Value> {+ kicad_core::ipc::drc_snapshot(cli, board, text).map_err(|e| kicad_core::ipc::error_response(&e))+}++#[cfg(not(feature = "ipc"))]+fn drc_of(_cli: Option<&Path>, _board: &Path, _text: &str) -> Result<Value, Value> {+ Err(fail("ipc_feature_off", "This build has the ipc feature off", "kicad_run_drc checks the saved file."))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn list_library_reads_symbols_and_footprints_by_path() {+ let d = std::env::temp_dir().join(format!("kb-listlib-{}", std::process::id()));+ let pretty = d.join("Mine.pretty");+ std::fs::create_dir_all(&pretty).unwrap();+ std::fs::write(d.join("Mine.kicad_sym"), "(kicad_symbol_lib (version 20241209) (symbol \"R\" (symbol \"R_0_1\")) (symbol \"LED\"))").unwrap();+ std::fs::write(pretty.join("SOT-23.kicad_mod"), "(footprint \"SOT-23\")").unwrap();+ std::fs::write(pretty.join("R_0603.kicad_mod"), "(footprint \"R_0603\")").unwrap();+ let s = list_library(&json!({"libraryPath": norm(&d.join("Mine.kicad_sym"))}));+ assert_eq!((s["kind"].as_str(), s["data"]["names"].clone()), (Some("sch"), json!(["R", "LED"])), "{s}");+ let f = list_library(&json!({"filePath": format!("{}/", norm(&pretty)), "limit": 1}));+ assert_eq!((f["kind"].as_str(), f["data"]["count"].as_u64(), f["data"]["names"].clone()), (Some("pcb"), Some(2), json!(["R_0603"])), "{f}");+ assert_eq!(list_library(&json!({"libraryPath": "C:/x/board.kicad_pcb"}))["errorCode"], "bad_args");+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn every_preset_names_layers_and_bottom_ones_are_mirrored() {+ assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty()));+ assert!(PRESETS.iter().filter(|p| p.0.starts_with("bottom") || p.0 == "assembly_bottom").all(|p| p.2));+ assert_eq!(PRESETS.len(), 14, "the standard's fourteen presets");+ }+}+@@ -1,1883 +1,2048 @@⋯ 1614 unchanged lines ⋯ } /// Every net name the board declares or uses.-fn board_net_names(root: &Node) -> Vec<String> {+pub fn board_net_names(root: &Node) -> Vec<String> { let mut names = Vec::new(); root.walk(&mut |k| { if k.tag() == Some("net") || k.tag() == Some("net_name") {⋯ 133 unchanged lines ⋯ Ok(out) } +// -- footprint_primitives ---------------------------------------------------------------------------++const MIL: f64 = 0.0254;++fn mil(v: f64) -> f64 {+ (v / MIL * 100.0).round() / 100.0+}++/// `kicad_footprint_primitives`: one placed footprint's geometry on the board (the standard's footprint_primitives):+/// its graphic lines (track) and arcs, pads (with solder and paste mask expansion in um) and texts, in mils in KiCad's+/// board frame, each with its layer; the other kinds (polygons, circles, rectangles, zones, models) are counted.+pub fn footprint_primitives(text: &str, reference: &str) -> RResult<Value> {+ let root = parse_root(text)?;+ let fp = footprints(&root).into_iter().find(|f| fp_ref(f).eq_ignore_ascii_case(reference))+ .ok_or_else(|| err("part_not_found", format!("the board has no part {reference}")))?;+ let (x, y, rot) = fp_text::pose(fp);+ let at = |n: &Node, tag: &str| -> Option<(f64, f64)> {+ let p = n.kid(tag)?;+ Some(placement::to_board((x, y), rot, (p.f64_at(1)?, p.f64_at(2)?)))+ };+ let pt = |p: (f64, f64)| json!([mil(p.0), mil(p.1)]);+ let stroke = |n: &Node| n.kid("stroke").and_then(|s| s.kid("width")).or_else(|| n.kid("width")).and_then(|w| w.f64_at(1)).map(mil);+ let fp_mask = fp.kid("solder_mask_margin").and_then(|m| m.f64_at(1));+ let fp_paste = fp.kid("solder_paste_margin").and_then(|m| m.f64_at(1));+ let mut prims = Vec::new();+ let mut other: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+ for k in fp.kids.iter().filter(|k| k.is_list()) {+ let layer = k.value_of("layer").map(str::to_string);+ match k.tag() {+ Some("fp_line") => prims.push(json!({"kind": "track", "layerId": layer, "start": at(k, "start").map(pt), "end": at(k, "end").map(pt), "widthMil": stroke(k)})),+ Some("fp_arc") => prims.push(json!({"kind": "arc", "layerId": layer, "start": at(k, "start").map(pt), "mid": at(k, "mid").map(pt), "end": at(k, "end").map(pt), "widthMil": stroke(k)})),+ Some("pad") => {+ let pa = k.kid("at");+ let local = (pa.and_then(|a| a.f64_at(1)).unwrap_or(0.0), pa.and_then(|a| a.f64_at(2)).unwrap_or(0.0));+ let size = k.kid("size");+ let layers: Vec<String> = k.kid("layers").map(|l| l.kids.iter().skip(1).filter_map(|a| a.atom.clone()).collect()).unwrap_or_default();+ let um = |own: Option<f64>, fallback: Option<f64>| own.or(fallback).map(|m| (m * 1000.0).round());+ prims.push(json!({+ "kind": "pad", "name": k.atom(1), "type": k.atom(2), "shape": k.atom(3),+ "at": pt(placement::to_board((x, y), rot, local)), "rotation": pa.and_then(|a| a.f64_at(3)).unwrap_or(0.0),+ "sizeMil": [size.and_then(|s| s.f64_at(1)).map(mil), size.and_then(|s| s.f64_at(2)).map(mil)],+ "holeMil": k.kid("drill").and_then(|d| d.kids.iter().skip(1).find_map(|a| a.atom.as_deref().and_then(|v| v.parse::<f64>().ok()))).map(mil),+ "layerId": layers.first(), "layers": layers, "net": k.kid("net").and_then(|n| n.kids.iter().skip(1).filter(|a| a.quoted).last()).and_then(|a| a.atom.clone()),+ "solderMaskExpansionUm": um(k.kid("solder_mask_margin").and_then(|m| m.f64_at(1)), fp_mask),+ "pasteMaskExpansionUm": um(k.kid("solder_paste_margin").and_then(|m| m.f64_at(1)), fp_paste),+ }));+ }+ Some("fp_text") | Some("property") if k.kid("layer").is_some() && k.kid("at").is_some() => {+ let hidden = k.has_word("hide") || k.value_of("hide") == Some("yes") || k.kid("effects").map_or(false, |e| e.has_word("hide") || e.value_of("hide") == Some("yes"));+ let height = k.kid("effects").and_then(|e| e.kid("font")).and_then(|f| f.kid("size")).and_then(|s| s.f64_at(1)).map(mil);+ // (property "Reference" "R1" ...) and (fp_text reference "R1" ...) both name the field, then the text+ prims.push(json!({"kind": "text", "layerId": layer, "text": k.atom(2), "field": k.atom(1), "at": at(k, "at").map(pt), "heightMil": height, "hidden": hidden}));+ }+ Some(t) if t.starts_with("fp_") || matches!(t, "zone" | "model" | "group") => *other.entry(t.trim_start_matches("fp_").to_string()).or_default() += 1,+ _ => {}+ }+ }+ Ok(json!({"designator": fp_ref(fp), "pattern": fp.atom(1), "count": prims.len(), "otherPrimitives": other, "primitives": prims,+ "at": [mil(x), mil(y)], "rotation": rot, "side": fp_text::side(fp), "units": "mil (KiCad's board frame, y down)"}))+}++// -- set_track_width ----------------------------------------------------------------------------++/// A track or arc's net name: KiCad 10's `(net "GND")`, or an older file's `(net 3)` through the board's net table.+fn item_net(item: &Node, table: &std::collections::HashMap<String, String>) -> String {+ match item.kid("net") {+ Some(n) if n.kids.get(1).map_or(false, |k| k.quoted) => n.atom(1).unwrap_or("").to_string(),+ Some(n) => n.atom(1).and_then(|c| table.get(c)).cloned().unwrap_or_default(),+ None => String::new(),+ }+}++/// The copper layer a standard layer word names: top, bottom, `mid N` (inner layer N), or KiCad's own name.+pub fn track_layer(word: &str) -> String {+ let w = word.trim();+ match w.to_ascii_lowercase().as_str() {+ "top" => "F.Cu".into(),+ "bottom" => "B.Cu".into(),+ l if l.starts_with("mid") => format!("In{}.Cu", l.trim_start_matches("mid").trim()),+ _ => w.to_string(),+ }+}++/// The board's tracks and arcs on `nets` (and `layer`, a KiCad layer name, when given): (uuid, net, width now).+pub fn tracks_on(text: &str, nets: &[String], layer: Option<&str>) -> RResult<Vec<(String, String, f64)>> {+ let root = parse_root(text)?;+ let table: std::collections::HashMap<String, String> = root.kids_tagged("net").filter_map(|n| Some((n.atom(1)?.to_string(), n.atom(2)?.to_string()))).collect();+ let mut out = Vec::new();+ for item in root.kids.iter().filter(|k| matches!(k.tag(), Some("segment") | Some("arc"))) {+ let net = item_net(item, &table);+ if !nets.iter().any(|n| n == &net) {+ continue;+ }+ if let Some(l) = layer {+ if item.value_of("layer") != Some(l) {+ continue;+ }+ }+ let Some(id) = item.uuid() else { continue };+ out.push((id.to_string(), net, item.kid("width").and_then(|w| w.f64_at(1)).unwrap_or(0.0)));+ }+ Ok(out)+}++/// `kicad_set_track_width` on a closed file: every track and arc on `nets` (and `layer`) gets `width` mm, each+/// keeping its uuid, ends and net. The report counts the changes per net.+pub fn set_track_width(text: &str, nets: &[String], layer: Option<&str>, width: f64) -> RResult<Change> {+ if !(width > 0.0 && width < 50.0) {+ return Err(err("invalid_args", format!("widthMm must be between 0 and 50 mm, not {width}")));+ }+ let hits = tracks_on(text, nets, layer)?;+ let root = parse_root(text)?;+ let mut e = Edits::new();+ let mut by_net: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+ for item in root.kids.iter().filter(|k| matches!(k.tag(), Some("segment") | Some("arc"))) {+ let Some(id) = item.uuid() else { continue };+ let Some((_, net, now)) = hits.iter().find(|(i, _, _)| i == id) else { continue };+ *by_net.entry(net.clone()).or_default() += 1;+ if (now - width).abs() < 1e-9 {+ continue;+ }+ match item.kid("width") {+ Some(w) => e.replace_node(w, format!("(width {})", n(width))),+ None => e.insert(item.end - 1, format!(" (width {})", n(width))),+ }+ }+ let changed = hits.iter().filter(|(_, _, w)| (w - width).abs() >= 1e-9).count();+ let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+ Ok(Change {+ candidate, copper: true,+ report: json!({"changed": changed, "matched": hits.len(), "byNet": by_net, "widthMm": width}),+ label: format!("Adom track width {} mm", n(width)),+ ..Default::default()+ })+}+ /// Every board pour's settings (pour_settings), for reading an edit back. pub fn all_pour_settings(text: &str) -> RResult<Vec<Value>> { let root = parse_root(text)?;⋯ 93 unchanged lines ⋯ } #[test]+ fn footprint_primitives_puts_a_parts_geometry_on_the_board_in_mils() {+ let t = "(kicad_pcb (version 20260206)\n\t(footprint \"R_0603\" (layer \"F.Cu\") (uuid \"f1\") (at 25.4 50.8 90)\n\t\t(property \"Reference\" \"R1\" (at 0 -1.27 0) (layer \"F.SilkS\") (effects (font (size 1 1))))\n\t\t(fp_line (start -0.254 0) (end 0.254 0) (stroke (width 0.12)) (layer \"F.SilkS\"))\n\t\t(fp_circle (center 0 0) (end 0.1 0) (layer \"F.Fab\"))\n\t\t(pad \"1\" smd roundrect (at -0.762 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (net \"VIN\") (solder_mask_margin 0.05))\n\t\t(pad \"2\" thru_hole circle (at 0.762 0 90) (size 1.7 1.7) (drill 1.0) (layers \"*.Cu\" \"*.Mask\"))\n\t)\n)\n";+ let v = footprint_primitives(t, "r1").unwrap();+ assert_eq!((v["designator"].as_str(), v["pattern"].as_str(), v["count"].as_u64()), (Some("R1"), Some("R_0603"), Some(4)), "{v}");+ assert_eq!(v["otherPrimitives"]["circle"], 1);+ let pad1 = v["primitives"].as_array().unwrap().iter().find(|p| p["name"] == "1").unwrap().clone();+ // rotated 90 degrees about (25.4, 50.8): local x -0.762 lands 0.762 mm below the anchor in KiCad's y-down frame+ assert_eq!((pad1["at"][0].as_f64(), pad1["at"][1].as_f64()), (Some(1000.0), Some(2030.0)), "{pad1}");+ assert_eq!((pad1["solderMaskExpansionUm"].as_f64(), pad1["net"].as_str()), (Some(50.0), Some("VIN")));+ let pad2 = v["primitives"].as_array().unwrap().iter().find(|p| p["name"] == "2").unwrap().clone();+ assert_eq!(pad2["holeMil"].as_f64(), Some(39.37));+ assert_eq!(footprint_primitives(t, "U9").unwrap_err().code, "part_not_found");+ }++ #[test]+ fn set_track_width_changes_the_named_nets_tracks_on_the_layer_asked() {+ let t = "(kicad_pcb (version 20260206)\n\t(segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net \"VBUS\") (uuid \"s1\"))\n\t(segment (start 1 0) (end 2 0) (width 0.2) (layer \"B.Cu\") (net \"VBUS\") (uuid \"s2\"))\n\t(arc (start 2 0) (mid 2.5 0.5) (end 3 0) (width 0.25) (layer \"F.Cu\") (net \"VBUS\") (uuid \"a1\"))\n\t(segment (start 0 5) (end 1 5) (width 0.2) (layer \"F.Cu\") (net \"SIG\") (uuid \"s3\"))\n)\n";+ let c = set_track_width(t, &["VBUS".into()], Some(&track_layer("top")), 0.5).unwrap();+ assert_eq!((c.report["changed"].as_u64(), c.report["byNet"]["VBUS"].as_u64()), (Some(2), Some(2)), "{}", c.report);+ assert!(c.candidate.contains("(width 0.5) (layer \"F.Cu\") (net \"VBUS\") (uuid \"s1\")") && c.candidate.contains("(width 0.5) (layer \"F.Cu\") (net \"VBUS\") (uuid \"a1\")"));+ assert!(c.candidate.contains("(width 0.2) (layer \"B.Cu\") (net \"VBUS\") (uuid \"s2\")") && c.candidate.contains("(net \"SIG\") (uuid \"s3\")"), "other layers and nets untouched");+ let all = set_track_width(t, &["VBUS".into()], None, 0.5).unwrap();+ assert_eq!(all.report["changed"].as_u64(), Some(3));+ assert_eq!(track_layer("mid 2"), "In2.Cu");+ assert_eq!(set_track_width(t, &["VBUS".into()], None, 0.0).unwrap_err().code, "invalid_args");+ }++ #[test] fn edit_pours_writes_an_older_files_net_number_and_name() { let t = "(kicad_pcb (version 20240108) (net 0 \"\") (net 1 \"GND\") (net 2 \"VCC\")\n (zone (net 1) (net_name \"GND\") (layer \"F.Cu\") (tstamp \"z9\") (hatch edge 0.5) (connect_pads (clearance 0.2)) (min_thickness 0.25) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))\n)\n"; let c = edit_pours(t, &json!({"pours": [{"id": "z9", "net": "VCC"}]})).unwrap();⋯ 21 unchanged lines ⋯ assert_eq!(super::lib_name("Package_SO:SOIC-8"), ("Package_SO".to_string(), "SOIC-8".to_string())); } }+@@ -1,912 +1,1011 @@⋯ 747 unchanged lines ⋯ } } +/// The kind a KiCad constraint belongs to, named as the standard's rules_by_kind names them (Clearance, Width,+/// RoutingVias, ...).+fn kind_of(constraint: &str) -> String {+ match constraint {+ "clearance" | "min_clearance" | "minClearance" => "Clearance".into(),+ "track_width" | "min_track_width" | "minTrackWidth" | "trackWidth" | "connection_width" | "minConnection" => "Width".into(),+ "via_diameter" | "annular_width" | "minViaDiameter" | "minViaAnnularWidth" | "viaDiameter" | "minMicroviaDiameter" | "minMicroviaDrill" => "RoutingVias".into(),+ "hole_size" | "minThroughHoleDiameter" | "viaDrill" => "HoleSize".into(),+ "hole_clearance" | "minHoleClearance" => "HoleClearance".into(),+ "hole_to_hole" | "minHoleToHole" => "HoleToHoleClearance".into(),+ "edge_clearance" | "minCopperEdgeClearance" => "BoardOutlineClearance".into(),+ "silk_clearance" | "minSilkClearance" => "SilkToSilkClearance".into(),+ "text_height" | "text_thickness" | "minTextHeight" | "minTextThickness" => "SilkscreenText".into(),+ "diff_pair_gap" | "diff_pair_uncoupled" | "diffPairWidth" | "diffPairGap" => "DiffPairsRouting".into(),+ "length" => "Length".into(),+ "skew" => "MatchedLengths".into(),+ "courtyard_clearance" => "ComponentClearance".into(),+ "physical_clearance" | "physical_hole_clearance" => "PhysicalClearance".into(),+ "disallow" => "RoutingDisallow".into(),+ "solderMaskToCopperClearance" | "solder_mask_expansion" => "SolderMaskExpansion".into(),+ "solder_paste_abs_margin" | "solder_paste_rel_margin" => "PasteMaskExpansion".into(),+ "zone_connection" | "thermal_relief_gap" | "thermal_spoke_width" | "min_resolved_spokes" => "PolygonConnect".into(),+ other => other.split('_').map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default() }).collect(),+ }+}++/// The standard's rules_by_kind from a KiCad project: its custom rules (.kicad_dru), its net classes and its Board+/// Setup minimums, grouped by kind, each kind sorted by priority (1 wins). KiCad's own precedence sets the order: a+/// later custom rule overrides an earlier one, custom rules override net classes (whose own priority orders them),+/// and both override Board Setup's minimums. KiCad rules are always enabled.+pub fn rules_by_kind(pro: &Value, dru: Option<&str>) -> Value {+ let mut rows: Vec<Value> = Vec::new();+ let mut problems: Vec<String> = Vec::new();+ // custom rules, the last in the file first+ if let Some(text) = dru {+ match parse_dru(text) {+ Ok((_, rules)) => {+ for r in rules.iter().rev() {+ for c in &r.constraints {+ let ty = c.get("type").and_then(Value::as_str).unwrap_or("");+ rows.push(json!({"name": r.name, "kind": kind_of(ty), "constraint": ty, "enabled": true, "source": "custom",+ "scope1": r.condition.clone().unwrap_or_else(|| "All".into()), "scope2": "All", "layer": r.layer, "severity": r.severity,+ "min": c.get("min"), "max": c.get("max"), "opt": c.get("opt"), "args": c.get("args"), "comment": "KiCad custom rule (.kicad_dru)"}));+ }+ }+ }+ Err(p) => problems = p,+ }+ }+ // net classes, by their own priority (lower first), Default last+ let mut cls = classes(pro);+ cls.sort_by_key(|c| if c.get("name").and_then(Value::as_str) == Some("Default") { i64::MAX } else { c.get("priority").and_then(Value::as_i64).unwrap_or(i64::MAX - 1) });+ for c in &cls {+ let name = c.get("name").and_then(Value::as_str).unwrap_or("");+ let scope = if name == "Default" { "All".to_string() } else { format!("NetClass == '{name}'") };+ for (field, key) in [("clearance", "clearance"), ("trackWidth", "track_width"), ("viaDiameter", "via_diameter"), ("viaDrill", "via_drill"), ("diffPairWidth", "diff_pair_width"), ("diffPairGap", "diff_pair_gap")] {+ if let Some(v) = c.get(key).filter(|v| v.is_number()) {+ rows.push(json!({"name": format!("{name} netclass {field}"), "kind": kind_of(field), "constraint": key, "enabled": true, "source": "netclass",+ "scope1": scope, "scope2": "All", "opt": v, "comment": format!("KiCad net class {name}")}));+ }+ }+ }+ // Board Setup minimums, every net+ let report = board_rules_report(pro);+ for (camel, key) in RULE_FIELDS {+ if let Some(v) = report["constraints"].get(*camel).filter(|v| v.is_number()) {+ rows.push(json!({"name": format!("Board Setup {camel}"), "kind": kind_of(camel), "constraint": key, "enabled": true, "source": "board",+ "scope1": "All", "scope2": "All", "min": v, "comment": "KiCad Board Setup > Constraints"}));+ }+ }+ let mut by_kind: std::collections::BTreeMap<String, Vec<Value>> = std::collections::BTreeMap::new();+ for r in rows {+ by_kind.entry(r["kind"].as_str().unwrap_or("").to_string()).or_default().push(r);+ }+ let mut count = 0;+ for list in by_kind.values_mut() {+ for (i, r) in list.iter_mut().enumerate() {+ r["priority"] = json!(i + 1);+ }+ count += list.len();+ }+ json!({"rulesByKind": by_kind, "count": count, "problems": problems})+}+ pub fn rule_json(r: &DruRule) -> Value { json!({"name": r.name, "layer": r.layer, "condition": r.condition, "severity": r.severity, "constraints": r.constraints, "text": r.text}) }⋯ 155 unchanged lines ⋯ assert!(parse_dru("(version 1)\n(rule \"a\" (constraint clearance (min 1mm))").is_err()); assert!(rule_text(&json!({"name": "x", "constraints": [{"type": "bogus"}]})).is_err()); }++ #[test]+ fn rules_by_kind_orders_custom_rules_then_classes_then_board_setup() {+ let pro = json!({"board": {"design_settings": {"rules": {"min_clearance": 0.15, "min_track_width": 0.127, "min_hole_to_hole": 0.25}}},+ "net_settings": {"classes": [{"name": "Default", "clearance": 0.2, "track_width": 0.2, "priority": 2147483647}, {"name": "Power", "clearance": 0.3, "track_width": 0.6, "priority": 0}]}});+ let dru = "(version 1)\n(rule \"HV\" (condition \"A.NetClass == 'HV'\") (constraint clearance (min 1mm)))\n(rule \"USB\" (condition \"A.NetClass == 'USB'\") (constraint clearance (min 0.15mm)) (constraint track_width (opt 0.3mm)))\n";+ let v = super::rules_by_kind(&pro, Some(dru));+ let clr = v["rulesByKind"]["Clearance"].as_array().unwrap();+ let names: Vec<&str> = clr.iter().map(|r| r["name"].as_str().unwrap()).collect();+ assert_eq!(names, vec!["USB", "HV", "Power netclass clearance", "Default netclass clearance", "Board Setup minClearance"], "{v}");+ assert_eq!((clr[0]["priority"].as_u64(), clr[0]["scope1"].as_str(), clr[2]["scope1"].as_str(), clr[4]["scope1"].as_str()), (Some(1), Some("A.NetClass == 'USB'"), Some("NetClass == 'Power'"), Some("All")));+ assert_eq!(v["rulesByKind"]["Width"].as_array().unwrap().len(), 4, "USB, Power, Default, Board Setup");+ assert_eq!(v["rulesByKind"]["HoleToHoleClearance"][0]["min"], 0.25);+ } }+@@ -1,46 +1,48 @@⋯ 35 unchanged lines ⋯ "Win32_System_SystemInformation", # GetTickCount (seconds_since_input) "Win32_Media_Multimedia", # mciSendStringW: tour narration audio without a window (tour_input) "Win32_System_LibraryLoader", # LoadLibraryExW/GetProcAddress: KiCad's ngspice.dll for kicad_simulate (ngspice.rs)+ "Win32_Storage_FileSystem", # GetLogicalDrives/GetDriveTypeW: KiCad installed on another drive (wiki #137) ] } uiautomation = "0.24" ⋯ 2 unchanged lines ⋯ # Linux-only crates go here (Barrett). Candidates: x11rb, zbus (portal screenshots), atspi. [target.'cfg(target_os = "linux")'.dependencies]+@@ -1,368 +1,409 @@⋯ 334 unchanged lines ⋯ }) } +/// Where a KiCad install can sit under each drive root, beyond what the registry names (wiki #137: KiCad on D:):+/// `<root>\Program Files\KiCad`, `<root>\Program Files (x86)\KiCad` and `<root>\KiCad`, each holding version folders.+pub fn drive_install_bases(roots: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {+ roots.iter().flat_map(|r| [r.join("Program Files").join("KiCad"), r.join("Program Files (x86)").join("KiCad"), r.join("KiCad")]).collect()+}++/// The version folders under one install base that hold `bin/<cli_name>`: (folder name, the kicad-cli path).+pub fn version_dirs(base: &std::path::Path, cli_name: &str) -> Vec<(String, std::path::PathBuf)> {+ let Ok(rd) = std::fs::read_dir(base) else { return Vec::new() };+ let mut out: Vec<(String, std::path::PathBuf)> = rd+ .flatten()+ .filter(|e| e.path().is_dir())+ .map(|e| (e.file_name().to_string_lossy().to_string(), e.path().join("bin").join(cli_name)))+ .filter(|(name, cli)| name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && cli.is_file())+ .collect();+ out.sort();+ out+}+ pub fn norm(p: &std::path::Path) -> String { p.to_string_lossy().replace('\\', "/") }⋯ 25 unchanged lines ⋯ pub fn native() -> &'static dyn Platform { &NATIVE }++#[cfg(test)]+mod install_root_tests {+ use super::*;++ #[test]+ fn a_kicad_on_another_drive_is_found_under_its_root() {+ // a fake drive root (wiki #137: D:\Program Files\KiCad\9.0 and D:\KiCad\10.0)+ let root = std::env::temp_dir().join(format!("kb-drive-{}", std::process::id()));+ for v in [root.join("Program Files/KiCad/9.0/bin"), root.join("KiCad/10.0/bin"), root.join("KiCad/notes")] {+ std::fs::create_dir_all(&v).unwrap();+ }+ std::fs::write(root.join("Program Files/KiCad/9.0/bin/kicad-cli.exe"), "").unwrap();+ std::fs::write(root.join("KiCad/10.0/bin/kicad-cli.exe"), "").unwrap();+ let bases = drive_install_bases(&[root.clone()]);+ assert_eq!(bases.len(), 3);+ let found: Vec<String> = bases.iter().flat_map(|b| version_dirs(b, "kicad-cli.exe")).map(|(n, _)| n).collect();+ assert_eq!(found, vec!["9.0".to_string(), "10.0".to_string()], "every version folder with a kicad-cli, nothing else");+ let _ = std::fs::remove_dir_all(&root);+ }+}+@@ -1,520 +1,579 @@⋯ 61 unchanged lines ⋯ out } +/// KiCad's uninstall entries (HKLM, HKLM WOW6432Node, HKCU): InstallLocation names the install root even when the+/// SOFTWARE\KiCad key is missing (wiki #137).+fn uninstall_installs() -> Vec<KicadInstall> {+ use winreg::enums::*;+ use winreg::RegKey;+ let mut out = Vec::new();+ for (hive, key, per_user) in [+ (HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", false),+ (HKEY_LOCAL_MACHINE, "SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", false),+ (HKEY_CURRENT_USER, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", true),+ ] {+ let Ok(k) = RegKey::predef(hive).open_subkey(key) else { continue };+ for sub in k.enum_keys().flatten() {+ let Ok(e) = k.open_subkey(&sub) else { continue };+ let name: String = e.get_value("DisplayName").unwrap_or_default();+ if !name.to_ascii_lowercase().starts_with("kicad") {+ continue;+ }+ let Ok(loc): Result<String, _> = e.get_value("InstallLocation") else { continue };+ let ver: String = e.get_value("DisplayVersion").unwrap_or_default();+ let cli = Path::new(loc.trim_matches('"')).join("bin").join("kicad-cli.exe");+ if let Some(i) = install_from_cli(&cli, &crate::major_minor(&ver), "uninstall key", per_user) {+ out.push(i);+ }+ }+ }+ out+}++/// Every fixed drive's root (C:\, D:\, ...): KiCad may be installed on any of them (wiki #137).+fn fixed_drive_roots() -> Vec<PathBuf> {+ use windows::core::PCWSTR;+ use windows::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives};+ const DRIVE_FIXED: u32 = 3;+ let mask = unsafe { GetLogicalDrives() };+ (0..26u32)+ .filter(|i| mask & (1 << i) != 0)+ .map(|i| format!("{}:\\", (b'A' + i as u8) as char))+ .filter(|root| {+ let w: Vec<u16> = root.encode_utf16().chain(Some(0)).collect();+ unsafe { GetDriveTypeW(PCWSTR(w.as_ptr())) == DRIVE_FIXED }+ })+ .map(PathBuf::from)+ .collect()+}++/// kicad-cli on PATH: a portable or unusual install the other searches miss.+fn path_installs() -> Vec<KicadInstall> {+ let Some(path) = std::env::var_os("PATH") else { return Vec::new() };+ std::env::split_paths(&path)+ .map(|d| d.join("kicad-cli.exe"))+ .filter(|c| c.is_file())+ .filter_map(|c| install_from_cli(&c, "", "PATH", false))+ .collect()+}+ fn folder_installs() -> Vec<KicadInstall> { let mut bases: Vec<(PathBuf, bool)> = Vec::new(); for var in ["ProgramFiles", "ProgramFiles(x86)"] {⋯ 4 unchanged lines ⋯ if let Some(l) = localappdata() { bases.push((l.join("Programs").join("KiCad"), true)); }+ // and the same folders on every fixed drive, not only the system one (wiki #137: KiCad on D:)+ for b in crate::drive_install_bases(&fixed_drive_roots()) {+ if !bases.iter().any(|(x, _)| x.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) {+ bases.push((b, false));+ }+ } let mut out = Vec::new(); for (base, per_user) in bases {- let Ok(rd) = std::fs::read_dir(&base) else { continue };- for e in rd.flatten() {- let name = e.file_name().to_string_lossy().to_string();- if e.path().is_dir() && name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {- let cli = e.path().join("bin").join("kicad-cli.exe");- if let Some(i) = install_from_cli(&cli, &name, "folder", per_user) {- out.push(i);- }+ for (name, cli) in crate::version_dirs(&base, "kicad-cli.exe") {+ if let Some(i) = install_from_cli(&cli, &name, "folder", per_user) {+ out.push(i); } } }⋯ 18 unchanged lines ⋯ "kicad-cli.exe" } fn find_installs(&self) -> Vec<KicadInstall> {+ // the registry first, then the uninstall entries, every fixed drive's install folders, then PATH (wiki #137) let mut installs = registry_installs();- for f in folder_installs() {+ for f in uninstall_installs().into_iter().chain(folder_installs()).chain(path_installs()) { if !installs.iter().any(|i| i.bin_dir.eq_ignore_ascii_case(&f.bin_dir)) { installs.push(f); }⋯ 404 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&dir); } }+@@ -1,25 +1,26 @@ [package] name = "adom-eda-checks"-version = "0.23.0"+version = "0.24.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,130 +1,133 @@⋯ 13 unchanged lines ⋯ | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs | | `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs | | `board_report` | board, and every companion it can use | every check the engine has, run on this bundle, as one self-contained HTML page (`text`), dark by default: fab limits (with `fab`), JLCPCB assembly, schematic review and symbols against footprints (with `sheetParts` and `netlist`), impedance, return paths and copper balance (with `stack`), 3D heights (with `bodies`), the design review, and the tool's own DRC (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them; `args` carries `lcscMissing`, `panelMm`, `gridMm` and `alwaysChecked` to those checks. A section whose input is missing says which to pass. From the Altium bridge's report.rs |+| `bom` | sheetParts, board? | the bill of materials: fitted parts grouped one line per manufacturer part number (natural designator order), a variant applied (`args.variant`: notFitted, alternates), checks for what an order needs (missing MPN, maker or Mouser/DigiKey/LCSC numbers, lines mixing values, JLCPCB lines without LCSC, DNP, excluded and not-purchased parts, sheet and board mismatches), and the order files as text (`files[]`: bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv); from the Altium bridge's bom.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools | | `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) |⋯ 2 unchanged lines ⋯ | `find_space` | board, bodies? | free spots for a part (`part`, or `sizeMm`) near another (`near`) or a point (`at`): clear of other part boxes on that side by `clearanceMm`, of that side's routed tracks and the vias, `edgeMm` inside the outline (arcs sampled), nearest first, as where the part's origin goes; `check: true` lists parts that overlap (and, with bodies, those `stacked` under a raised one) or have pads off the outline, as placement_check judges them. Pours do not block (they re-pour). From the Altium bridge's design.rs | | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments |+| `cross_check` | sheetParts, board | the sheets' designators against the board's: matched, on the sheets but not the board, on the board but on no sheet | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), a shorter column spread over the longer one's span (SOT-23-5's pins 4 and 5 level with 3 and 1), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` round the pads and body grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |⋯ 100 unchanged lines ⋯ ``` Fixtures live on each bridge's own page, not here.+@@ -1,13 +1,14 @@⋯ 2 unchanged lines ⋯ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.28.0-- Standard commit: dc0d3953dd8e58d149b4bba145aaa3d7d7292886-- Crate version: 0.23.0+- Standard version: 0.30.0+- Standard commit: 25dd7d1e87e961f5fa197a728a1f2b2c3025175a+- Crate version: 0.24.0 - Copied: 2026-10-29, by kicad-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests. Never edit the copy: fixes go to the standard (an issue titled `kicad-lane: ...`).+@@ -0,0 +1,295 @@+//! The bill of materials, from the sheets' parts and the board: fitted parts grouped one line per manufacturer part+//! number, checked for what an order needs, and the order files as text (generic, JLCPCB BOM + CPL, Mouser and DigiKey+//! uploads). Ported from the Altium bridge's bom.rs so every bridge answers `bom` the same way; the bridge reads the+//! parts and writes the files. Also `cross_check`: the sheets' designators against the board's.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++/// Parameter names that mean the same thing across libraries and tools (Altium templates, the Adom part import, the+/// wiki's Cloud Library, Altium 365 supplier links, KiCad fields, EAGLE attributes).+const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "MANUFACTURER_PN", "Part Number", "PartNumber", "Part_Number"];+const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER", "MANUFACTURER_NAME"];+const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];+const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];+const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #", "JLCPCB"];+const VALUE: &[&str] = &["Value", "VALUE"];+const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];+const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];+const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];+const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];++fn st(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => String::new(), o => o.to_string() } }++/// R1 < R2 < R10 < R10A: the letters, then the number, then the rest.+pub fn natural_key(d: &str) -> (String, u64, String) {+ let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();+ let rest = &d[pre.len()..];+ let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+ (pre.to_ascii_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())+}++fn truthy(v: &str) -> bool { matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x") }++/// One part, merged from its sheet (parameters) and the board (footprint, placement).+#[derive(Debug, Clone, Default)]+pub struct Part {+ pub designator: String,+ pub comment: String,+ pub description: String,+ pub lib_ref: String,+ /// out of the BOM by its kind (Altium's graphical, net tie and standard-no-BOM kinds; KiCad's exclude from BOM)+ pub no_bom: bool,+ pub params: Vec<(String, String)>,+ pub footprint: String,+ pub placed: Option<(f64, f64, f64, String)>,+ pub dnp: bool,+ pub alternate: String,+ pub managed: bool,+ pub source_lib: String,+}++impl Part {+ fn get(&self, names: &[&str]) -> String {+ for n in names {+ if let Some((_, v)) = self.params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) { return v.trim().to_string(); }+ }+ String::new()+ }+ fn mpn(&self) -> String { self.get(MPN) }+ fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }+ /// Copper test pads, fiducials and the like carry an MPN or maker that says nothing is bought ("none (PCB copper)"):+ /// they stay in the BOM, marked, and out of the ordering and assembly files.+ fn not_purchased(&self) -> bool {+ [self.mpn(), self.get(MFR)].iter().any(|v| {+ let m = v.trim().to_ascii_lowercase();+ m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")+ })+ }+}++/// The parts from the sheetParts companion (units of one part merged by designator), with the board's footprint and+/// placement. Returns (parts, designators on the board but on no sheet).+pub fn parts(sheet: &[Value], board: Option<&Value>) -> (Vec<Part>, Vec<String>) {+ let mut by: BTreeMap<String, Part> = BTreeMap::new();+ for c in sheet {+ let d = st(&c["designator"]);+ if d.is_empty() || d.ends_with('?') { continue; }+ // params: [{name, value}] or {name: value}+ let params: Vec<(String, String)> = match &c["params"] {+ Value::Array(a) => a.iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect(),+ Value::Object(o) => o.iter().map(|(k, v)| (k.clone(), st(v))).collect(),+ _ => Vec::new(),+ };+ let kind = c["kind"].as_i64().unwrap_or(0);+ let no_bom = matches!(kind, 2 | 4 | 5) || c["inBom"] == false || c["excludeFromBom"] == true;+ let e = by.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {+ designator: d.clone(), comment: [&c["comment"], &c["value"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),+ description: st(&c["description"]), lib_ref: [&c["libRef"], &c["libReference"], &c["libId"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),+ no_bom, managed: !st(&c["itemGuid"]).is_empty(), source_lib: st(&c["sourceLibrary"]), dnp: c["dnp"] == true, ..Default::default() });+ // a multi-unit part appears once per unit: keep the first, add what it lacks+ for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }+ if c["dnp"] == true { e.dnp = true; }+ if e.footprint.is_empty() { e.footprint = [&c["footprint"], &c["pattern"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(); }+ }+ let mut board_only = Vec::new();+ if let Some(b) = board {+ for c in b["components"].as_array().cloned().unwrap_or_default() {+ let d = st(&c["ref"]);+ if d.is_empty() { continue; }+ let placed = Some((c["x"].as_f64().unwrap_or(0.0), c["y"].as_f64().unwrap_or(0.0), c["rotation"].as_f64().unwrap_or(0.0), if c["side"] == "bottom" { "bottom".to_string() } else { "top".to_string() }));+ match by.get_mut(&d.to_ascii_uppercase()) {+ Some(p) => { let pat = st(&c["pattern"]); if !pat.is_empty() { p.footprint = pat; } p.placed = placed; }+ None => board_only.push(d),+ }+ }+ }+ for p in by.values_mut() {+ if truthy(&p.get(DNP)) || p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }+ }+ board_only.sort_by_key(|d| natural_key(d));+ (by.into_values().collect(), board_only)+}++/// Group the fitted parts: one line per manufacturer part number, else per value + footprint. Returns (lines, checks).+pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {+ let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();+ for p in parts.iter().filter(|p| !p.dnp && !p.no_bom) {+ let m = p.mpn();+ let key = if m.is_empty() { format!("~{}|{}", p.value().to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };+ groups.entry(key).or_default().push(p);+ }+ let mut lines = Vec::new();+ let mut inconsistent = Vec::new();+ for (_, mut g) in groups {+ g.sort_by_key(|p| natural_key(&p.designator));+ let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();+ let mut line = Map::new();+ line.insert("quantity".into(), json!(g.len()));+ line.insert("designators".into(), json!(refs));+ for (name, names) in FIELDS {+ let mut distinct: Vec<String> = g.iter().map(|p| p.get(names)).filter(|v| !v.is_empty()).collect();+ distinct.sort(); distinct.dedup();+ if distinct.len() > 1 { inconsistent.push(json!({"field": name, "designators": refs, "values": distinct})); }+ line.insert((*name).into(), json!(distinct.first().cloned().unwrap_or_default()));+ }+ let first = g[0];+ let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();+ fps.sort(); fps.dedup();+ if fps.len() > 1 { inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps})); }+ line.insert("value".into(), json!(first.value()));+ line.insert("comment".into(), json!(first.comment));+ line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));+ line.insert("sourceLibrary".into(), json!(first.source_lib));+ line.insert("managed".into(), json!(first.managed));+ line.insert("footprint".into(), json!(fps.first().copied().unwrap_or("")));+ line.insert("package".into(), json!(first.get(PACKAGE)));+ line.insert("libReference".into(), json!(first.lib_ref));+ line.insert("purchase".into(), json!(!first.not_purchased()));+ lines.push(Value::Object(line));+ }+ lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));+ let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };+ let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased()).collect();+ let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());+ let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && st(&l["lcsc"]).is_empty())+ .map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();+ let checks = json!({+ "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),+ "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},+ "inconsistent": inconsistent,+ "dnp": sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect()),+ "excludedKinds": sorted(parts.iter().filter(|p| !p.dnp && p.no_bom).map(|p| p.designator.clone()).collect()),+ "notPurchased": sorted(parts.iter().filter(|p| !p.dnp && !p.no_bom && p.not_purchased()).map(|p| p.designator.clone()).collect()),+ "jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},+ });+ (lines, checks)+}++fn csv(rows: &[Vec<String>]) -> String {+ let cell = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };+ rows.iter().map(|r| r.iter().map(|c| cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"+}++/// The order files as (name, text). `boards` multiplies the quantities of the distributor uploads.+pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {+ let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();+ let mut out = Vec::new();+ let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];+ for (i, l) in lines.iter().enumerate() {+ rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);+ }+ out.push(("bom.csv".to_string(), csv(&rows)));+ // JLCPCB assembly: their BOM and CPL columns, mm, Top/Bottom+ let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];+ for l in lines.iter().filter(|l| l["purchase"] != json!(false)) { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }+ out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));+ let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];+ let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased() && p.placed.is_some()).collect();+ placed.sort_by_key(|p| natural_key(&p.designator));+ for p in placed {+ let (x, y, r, side) = p.placed.clone().unwrap();+ c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);+ }+ out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));+ // the distributors' BOM tools (Mouser BOM Tool, DigiKey myLists) map these headers directly+ for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {+ let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];+ for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) {+ m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);+ }+ out.push((name.to_string(), csv(&m)));+ }+ out+}++fn sheet_list(input: &Value) -> Option<Vec<Value>> {+ let p = crate::input::part(input, "sheetParts")?;+ p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned()+}++/// bom: input {sheetParts, board?, args: {variant? (the standard `variants` row: {name, notFitted: [ref], alternates:+/// [{designator, part | fields}]}), boards? (1)}}. Parts carry designator, comment or value, description, libRef,+/// footprint?, kind? (Altium) or inBom/excludeFromBom (KiCad), dnp?, params ([{name, value}] or {name: value}).+pub fn bom(input: &Value) -> Value {+ let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (every sheet, with their parameters) as sheetParts") };+ let board = crate::input::part(input, "board");+ let (mut list, board_only) = parts(&sheet, board.as_ref());+ let args = &input["args"];+ let variant = &args["variant"];+ let mut alternates = Vec::new();+ if variant.is_object() {+ let nf: BTreeSet<String> = variant["notFitted"].as_array().map(|a| a.iter().map(|x| st(x).to_ascii_uppercase()).collect()).unwrap_or_default();+ let alts: BTreeMap<String, String> = match &variant["alternates"] {+ Value::Array(a) => a.iter().map(|x| (st(&x["designator"]).to_ascii_uppercase(), if x["part"].is_null() { st(&x["fields"]) } else { st(&x["part"]) })).collect(),+ Value::Object(o) => o.iter().map(|(k, v)| (k.to_ascii_uppercase(), st(v))).collect(),+ _ => BTreeMap::new(),+ };+ for p in list.iter_mut() {+ let k = p.designator.to_ascii_uppercase();+ if nf.contains(&k) { p.dnp = true; }+ if let Some(a) = alts.get(&k) { p.alternate = a.clone(); alternates.push(json!({"designator": p.designator, "alternatePart": a})); }+ }+ }+ let (lines, mut checks) = group(&list);+ let mut not_on_board: Vec<String> = if board.is_some() { list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect() } else { Vec::new() };+ not_on_board.sort_by_key(|d| natural_key(d));+ checks["sheetNotOnBoard"] = json!(not_on_board);+ checks["boardNotOnSheet"] = json!(board_only);+ let boards = args["boards"].as_u64().unwrap_or(1);+ let fitted = list.iter().filter(|p| !p.dnp && !p.no_bom).count();+ let files: Vec<Value> = files(&lines, &list, boards).into_iter().map(|(n, t)| json!({"name": n, "text": t})).collect();+ json!({"success": true, "variant": variant["name"], "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks, "alternates": alternates, "boards": boards, "files": files,+ "_hint": "One line per manufacturer part number. Write files[] (bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv for JLCPCB assembly, bom-mouser.csv and bom-digikey.csv for those sites' BOM tools). checks: parts with no MPN or distributor number (find them with adom-parts-search and write them back with sheet_params), lines that mix values for one part number (checks.inconsistent), JLCPCB lines with no LCSC number, and parts on the sheets but not the board or the other way round."})+}++/// cross_check: input {sheetParts, board}. The sheets' designators against the board's.+pub fn cross_check(input: &Value) -> Value {+ let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts as sheetParts") };+ let st_ = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let (list, board_only) = parts(&sheet, Some(&st_));+ let mut sheet_only: Vec<String> = list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect();+ sheet_only.sort_by_key(|d| natural_key(d));+ let matched = list.iter().filter(|p| p.placed.is_some()).count();+ json!({"success": true, "schematicComponents": list.len(), "boardComponents": matched + board_only.len(), "matched": matched,+ "onSheetNotOnBoard": sheet_only, "onBoardNotOnSheet": board_only,+ "_hint": "onSheetNotOnBoard: update the board from the schematic (update_pcb). onBoardNotOnSheet: parts placed by hand or left after a schematic change; remove them (remove_parts) or add them to a sheet. Parts kept out of the BOM by kind are not counted as missing from the board."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn the_bom_groups_by_part_number_and_checks_the_order() {+ let p = |d: &str, v: &str, params: Value| json!({"designator": d, "comment": v, "libRef": "R", "params": params});+ let sheet = vec![+ p("R10", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98220"})),+ p("R2", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98221"})),+ p("R1", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo"})),+ p("C1", "100n", json!({"Value": "100nF"})),+ p("TP1", "TP", json!({"MPN": "none (PCB copper)"})),+ p("U1", "MCU", json!([{"name": "MPN", "value": "RP2040"}, {"name": "DNP", "value": "yes"}])),+ json!({"designator": "R?", "comment": "x"}),+ json!({"designator": "H1", "comment": "hole", "excludeFromBom": true}),+ ];+ let board = json!({"components": [{"ref": "R1", "pattern": "R_0603", "x": 1.0, "y": 2.0, "rotation": 450.0, "side": "top"}, {"ref": "R2", "pattern": "R_0603", "x": 3.0, "y": 2.0, "rotation": 0.0, "side": "bottom"}, {"ref": "J9", "pattern": "X", "x": 0.0, "y": 0.0}]});+ let r = bom(&json!({"sheetParts": sheet, "board": board, "args": {"boards": 5, "variant": {"name": "lite", "notFitted": ["C1"]}}}));+ // R1, R2, R10 on one line in natural order; TP1 its own line, not purchased; U1 and C1 not fitted; H1 excluded+ assert_eq!(r["lines"][0]["designators"], json!(["R1", "R2", "R10"]), "{r}");+ assert_eq!(r["lines"][0]["quantity"], 3);+ assert_eq!(r["lineCount"], 2);+ assert_eq!(r["checks"]["inconsistent"][0]["field"], "lcsc");+ assert_eq!(r["checks"]["dnp"], json!(["C1", "U1"]));+ assert_eq!(r["checks"]["excludedKinds"], json!(["H1"]));+ assert_eq!(r["checks"]["notPurchased"], json!(["TP1"]));+ assert_eq!(r["checks"]["boardNotOnSheet"], json!(["J9"]));+ assert_eq!(r["checks"]["sheetNotOnBoard"], json!(["C1", "R10", "TP1", "U1"]));+ let file = |n: &str| r["files"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["text"].as_str().unwrap().to_string();+ assert!(file("cpl-jlcpcb.csv").contains("R1,1.0000mm,2.0000mm,Top,90\r\n") && file("cpl-jlcpcb.csv").contains("R2,3.0000mm,2.0000mm,Bottom,0\r\n"), "{}", file("cpl-jlcpcb.csv"));+ assert!(file("bom-mouser.csv").contains(",RC0603FR-0710KL,Yageo,15,\"R1,R2,R10\""), "{}", file("bom-mouser.csv"));+ assert!(!file("bom-jlcpcb.csv").contains("TP1"));+ let x = cross_check(&json!({"sheetParts": [p("R1", "", json!({})), p("R2", "", json!({}))], "board": {"components": [{"ref": "R1"}, {"ref": "J9"}], "pads": []}}));+ assert_eq!((x["matched"].clone(), x["onSheetNotOnBoard"].clone(), x["onBoardNotOnSheet"].clone()), (json!(1), json!(["R2"]), json!(["J9"])), "{x}");+ }+}+@@ -1,97 +1,101 @@⋯ 9 unchanged lines ⋯ pub mod assembly; pub mod autoclass; pub mod bodies;+pub mod bom; pub mod coppermap; pub mod cost; pub mod current;⋯ 24 unchanged lines ⋯ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "bom", "check_clearance", "check_orientation", "copper_balance", "cross_check", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {⋯ 8 unchanged lines ⋯ "assembly_check" => assembly::assembly_check(input), "auto_classes" => autoclass::auto_classes(input), "board_report" => report::board_report(input),+ "bom" => bom::bom(input), "check_clearance" => placetools::check_clearance(input), "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input),+ "cross_check" => bom::cross_check(input), "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input), "find_space" => space::find_space(input),⋯ 31 unchanged lines ⋯ assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]); } }+@@ -1,1956 +1,2008 @@⋯ 204 unchanged lines ⋯ "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", "add_symbol_art", "build_symbol_modes", "cleanup_libs",- "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step")},+ "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",+ "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind")}, "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),⋯ 1516 unchanged lines ⋯ ok(pp) and pp.get("atMil") == [1000, 1000] and ok(pc) and pc.get("connected") == 2 and ok(pe) and pe.get("applied") == 2 and ok(pw) and (pw.get("now") or {}).get("libReference") == "Device:R_Small" and isinstance(pw.get("pinsJoined"), list), "a standard-shaped schematic part verb failed, or answered outside the standard's terms (atMil, connected, applied, now, pinsJoined)")+ # 1.0.95 (engine 0.24.0): cross_check on a copy of the rp2040 project with C1 taken off the board: C1 is on the+ # sheet but not the board, and every other part matches+ xd = base + "/xcheck"+ ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + xd.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + xd.replace("/", "\\") + "\"",+ "reason": "kicad-bridge verb test: a scratch copy of the rp2040 project for kicad_cross_check"}, timeout=120)+ x0 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200)+ ab(a.target, "kicad_delete_footprint", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "refs": ["C1"], "source": "file"}, timeout=120)+ x1 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200)+ own("cross_check", {"before": x0, "after": x1}, ok(x0) and ok(x1) and (x0.get("matched") or 0) > 5 and set(x1.get("onSheetNotOnBoard") or []) == set(x0.get("onSheetNotOnBoard") or []) | {"C1"}+ and x1.get("matched") == x0.get("matched", 0) - 1 and x1.get("sameProject") is True,+ "cross_check did not find C1 missing from the board after it was deleted, or the counts did not follow") # tool_update: check and the auto setting read (never apply: that installs KiCad); import_step refused with why tu = C("tool_update", {"action": "check"}, 120) ta = C("tool_update", {"action": "auto"}, 60)⋯ 1 unchanged line ⋯ "tool_update did not report the installed version, the offered one and the auto setting") st = C("import_step", {"filePath": "C:/x/enclosure.step"}, 30) own("import_step", st, st.get("errorCode") == "not_supported" and st.get("instead"), "import_step did not refuse by name with what to use instead")+ # the standard's list_library, render_board_layers, export_bom and open/activate verbs under their own names+ kb = ((ab(a.target, "kicad_readiness", {}, timeout=60).get("installs") or [{}])[0]).get("baseDir") or "C:/Program Files/KiCad/10.0"+ ls = C("list_library", {"libraryPath": kb + "/share/kicad/symbols/74xx.kicad_sym", "limit": 5}, 60)+ lf = C("list_library", {"libraryPath": kb + "/share/kicad/footprints/Resistor_SMD.pretty"}, 60)+ own("list_library", {"sym": ls, "pretty": lf}, ok(ls) and ls.get("kind") == "sch" and "7400" in ((ls.get("data") or {}).get("names") or []) and ok(lf) and lf.get("kind") == "pcb" and ((lf.get("data") or {}).get("count") or 0) > 20,+ "the symbol or footprint library was not listed by its path")+ rb = C("render_board_layers", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "preset": "bottom_copper", "dpi": 150, "outputPath": w["out"] + "/layers/bottom.png"}, 200)+ rl = C("render_board_layers", {"listPresets": True}, 30)+ own("render_board_layers", {"png": rb, "presets": rl}, ok(rb) and (rb.get("widthPx") or 0) > 100 and rb.get("mirrored") is True and rb.get("layers") == ["B.Cu", "Edge.Cuts"] and len(rl.get("presets") or {}) == 14,+ "the bottom copper was not rendered mirrored to a PNG, or the presets were not listed")+ rd = C("routing_drc", {"filePath": CK, "source": "file"}, 300)+ own("routing_drc", rd, ok(rd) and rd.get("clean") is False and (rd.get("unconnected") or 0) > 0 and all(v.get("rule") and v.get("at") for v in rd.get("violations") or []),+ "the ESC board's DRC did not come back not-clean with every violation placed")+ twd = base + "/tw"+ ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + twd.replace("/", "\\") + "\" 2>nul & mkdir \"" + twd.replace("/", "\\") + "\" & copy /y \"" + (base + "/checks/route-fixture.kicad_pcb").replace("/", "\\") + "\" \"" + twd.replace("/", "\\") + "\"",+ "reason": "kicad-bridge verb test: a scratch copy of the routed fixture for kicad_set_track_width"}, timeout=120)+ tw = C("set_track_width", {"filePath": twd + "/route-fixture.kicad_pcb", "nets": ["SIG_A"], "widthMm": 0.4, "layer": "top", "source": "file"}, 300)+ tr = ab(a.target, "kicad_routing_state", {"filePath": twd + "/route-fixture.kicad_pcb", "source": "file", "model": "standard"}, timeout=120)+ widths = {(t.get("layer"), t.get("width")) for t in tr.get("tracks") or [] if t.get("net") == "SIG_A"}+ own("set_track_width", {"edit": tw, "after": sorted(map(str, widths))}, ok(tw) and (tw.get("changed") or 0) > 0 and "drcAfter" in tw and ("F.Cu", 0.4) in widths and not any(l == "F.Cu" and w != 0.4 for l, w in widths),+ "SIG_A's top-layer tracks did not all read back at 0.4 mm")+ fpp = C("footprint_primitives", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "designator": "U1", "source": "file"}, 120)+ prim = (fpp.get("data") or {}).get("primitives") or []+ own("footprint_primitives", fpp, ok(fpp) and sum(1 for p in prim if p.get("kind") == "pad") >= 56 and all(p.get("layerId") for p in prim) and (fpp.get("data") or {}).get("pattern"),+ "U1's pads and graphics did not come back placed with their layers")+ rk = C("rules_by_kind", {"filePath": xd + "/rp2040-breakout.kicad_pcb"}, 60)+ kinds = rk.get("rulesByKind") or {}+ own("rules_by_kind", rk, ok(rk) and "Clearance" in kinds and "Width" in kinds and all(r.get("priority") == i + 1 for l in kinds.values() for i, r in enumerate(l)),+ "the rules did not come back grouped by kind in priority order")+ eb = C("export_bom", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "outputDir": w["out"] + "/bom-std"}, 200)+ own("export_bom", eb, ok(eb) and eb.get("files") and eb.get("surface") == "bom", "no BOM file was written")+ od = C("open_document", {"filePath": xd + "/rp2040-breakout.kicad_pcb"}, 200)+ ad = C("activate_document", {"path": xd + "/rp2040-breakout.kicad_pcb"}, 200)+ own("open_document", od, ok(od) and (od.get("data") or {}).get("objectKind") == "Board", "the scratch board did not open")+ own("activate_document", ad, ok(ad) and (ad.get("data") or {}).get("kind") == "pcb", "the open scratch board was not activated")+ ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch board open_document opened"}, timeout=120)+ op = C("open_project", {"filePath": xd + "/rp2040-breakout.kicad_pro"}, 200)+ own("open_project", op, ok(op) and (op.get("data") or {}).get("objectKind") == "Project", "the scratch project did not open in the project manager")+ ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch project open_project opened"}, timeout=120) im = C("import_design", {"format": "eagle", "filePath": base + "/import/divider.brd", "overwrite": True}, 300) io = C("import_design", {"format": "orcad", "filePath": base + "/import/divider.brd"}, 60) own("import_design", {"eagle": im, "orcad": io}, ok(im) and im.get("parts") == 3 and im.get("nets") == 3 and im.get("schematicImported") is False⋯ 213 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.