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.65, live adds built from library text, compile, list_installed_libraries, replicate_layout, diff_pairs #26
Merged opened by Colby Knox 2026-10-27
From kicad-lane. KiCad bridge 1.0.65.
Live adds without a copy on the board. add_footprint and add_mounting_hole build the part from its library text as KiCad's protobuf items (commit:"create-items", one Undo step): pads, graphics, texts, fields and 3D models.
- When the same library footprint is already on that side of the board, KiCad's own encoding of it is still copied.
- Checked on KiCad 10.0.7: stock parts with through-hole and SMD pads, at 0, 45, 90 and 270 degrees and on the back, came out the same as the closed-file add. DRC reports no library mismatch for them.
- A footprint with custom-shape pads, mask or paste margins, or zones inside still refuses with live_create_unsupported and says why.
New standard verbs:
- compile: ERC and the netlist, with messages counted by class;
- list_installed_libraries: every symbol and footprint library KiCad loads, with missing ones flagged;
- replicate_layout: one channel's placement onto matching channels through move_footprint, one Undo step;
- diff_pairs: the pairs KiCad's router recognises by name, and the net class that sets their width and gap.
Tests:
cargo test --workspace: 527 pass, plus the new test that no composite takes GUI_LOCK; the 7 knownschematic_edittests fail because their fixture was never pushed.- Full verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 263 pass. Its two failures were replicate_layout and diff_pairs, which deadlocked because replicate_layout held GUI_LOCK while calling a live step. That's fixed (composites are catalogued as File, and a test guards it), and Phase 9 then passed 52 of 52.
- Live adds without a copy on the board: add_footprint_built_live passed on the ESC board. On a scratch board, five stock parts (through-hole and SMD, 0/45/90/270 degrees, back side) matched the closed-file add, with no lib_footprint_mismatch from DRC.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,19 +1,20 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.11.2",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.11.2/adom-bridge-kicad-v0.11.2.zip",- "sha256": "2540c1e4e9c9c54f4bd3b26a69a895993f9dad8ae8f40393a8e11d74895ee12b",- "size": 451610,+ "version": "0.11.1",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.11.1/adom-bridge-kicad-v0.11.1.zip",+ "sha256": "700455ce7815006552c694097fb4249e2e4820116ae8a84d70e73a4cb23d3df3",+ "size": 451606, "platform": "macos", "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-10-27T19:55:04Z",+ "released_at": "2026-10-20T21:05:00Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python" ], "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/kicad-bridge/files/adom-bridge-kicad-manifest-macos.json" }+@@ -1,375 +1,380 @@⋯ 269 unchanged lines ⋯ "kicad_sheet_params", "kicad_silk_state", "kicad_symbol_prims",- "kicad_xcopy_footprint"+ "kicad_xcopy_footprint",+ "kicad_compile",+ "kicad_diff_pairs",+ "kicad_list_installed_libraries",+ "kicad_replicate_layout" ], "statusVerb": "kicad_status", "timeouts": {⋯ 96 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1297 +1,1298 @@⋯ 309 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.64"+version = "1.0.65" dependencies = [ "kicad-core", "kicad-platform",⋯ 4 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.64"+version = "1.0.65" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.64"+version = "1.0.65" 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.64"+version = "1.0.65" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,50 +1,52 @@⋯ 37 unchanged lines ⋯ Group { name: "place_helpers", verbs: || crate::verbs_place_helpers::VERBS, dispatch: crate::verbs_place_helpers::dispatch }, Group { name: "pcb_sync", verbs: || crate::verbs_pcb_sync::VERBS, dispatch: crate::verbs_pcb_sync::dispatch }, Group { name: "demo", verbs: || crate::verbs_demo::VERBS, dispatch: crate::verbs_demo::dispatch },+ Group { name: "composite", verbs: || crate::verbs_composite::VERBS, dispatch: crate::verbs_composite::dispatch }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 4 unchanged lines ⋯ } None }+@@ -1,229 +1,231 @@⋯ 34 unchanged lines ⋯ mod verbs_show; mod verbs_standard; mod std_shapes;+mod verbs_composite; mod verbs_windows; use std::sync::atomic::{AtomicU64, Ordering};⋯ 186 unchanged lines ⋯ }; let _ = req.respond(json_response(body, code)); }+@@ -1,619 +1,625 @@⋯ 35 unchanged lines ⋯ } } +/// One verb called from inside another (a composite verb), the same way a caller's would run.+pub(crate) fn call(state: &mut State, command: &str, args: &Value) -> Value {+ run(state, command, args, &Caller { thread: "kicad-bridge".into(), container: String::new(), reason: "a step of a composite verb".into() })+}+ fn run(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value { let Some(verb) = catalog::find(command) else { let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect();⋯ 575 unchanged lines ⋯ _ => catalog.into(), } }+@@ -1,855 +1,856 @@⋯ 21 unchanged lines ⋯ const LIVE_PITFALLS: &[&str] = &[ "Live when the PCB editor serves the board on the IPC API (one native Undo step; expectedRevision required, from kicad_routing_state, kicad_placement_state or the previous edit; KiCad's DRC on a snapshot first when copper changes), otherwise the closed file (atomic write, <board>.adom-bak beside it, source:\"file\"); source:\"file\" or \"live\" forces one. dryRun:true changes nothing.", "A board open in KiCad without the IPC API is refused (board_open): the editor would overwrite a file edit. Turn the API on (kicad_ipc_api {\"enable\":true}, relaunch) or close the board.",- "Live, new items go to KiCad as protobuf items (CreateItems) with fresh UUIDs: board graphics always, a footprint by copying KiCad's own encoding of the same library footprint already on the board. Anything else refuses with live_create_unsupported (KiCad 10.0.x does not create items from text) and changes nothing.",+ "Live, new items go to KiCad as protobuf items (CreateItems) with fresh UUIDs: board graphics, and footprints (copied from the same library footprint on the board, else built from the library text). A footprint with custom-shape pads, mask or paste margins or zones inside refuses with live_create_unsupported (KiCad 10.0.x does not create items from text) and changes nothing.", "Never replay a timed-out edit (mutation_outcome_unknown): read the board first.", ]; ⋯ 36 unchanged lines ⋯ example: "kicad_add_mounting_hole {\"filePath\":\"C:/p/b.kicad_pcb\",\"x\":104,\"y\":62,\"drill\":3.2,\"expectedRevision\":\"<rev>\"}", hint: "Stock drills (2.2 M2, 2.7 M2.5, 3.2 M3, 3.7, 4.3 M4, 5.3 M5, 6.4 M6, 8.4 M8) use MountingHole:MountingHole_<d>mm_<M> (_Pad when plated) from your KiCad library when it is installed and no padDiameter is given; the reply's footprint says which was used. A net makes it plated. reference \"H\" takes the next free H number.", related: &["kicad_add_footprint", "kicad_placement_validate", "kicad_board_outline"],- pitfalls: &["On a board OPEN in the PCB editor a hole is created live (commit:\"create-items\", one Undo step) when the same mounting-hole footprint is already on that side of the board; otherwise KiCad 10.0.x cannot create it over its API: the verb refuses with live_create_unsupported, says why in liveCreateWhy and changes nothing. Save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2], LIVE_PITFALLS[3]],+ pitfalls: &["On a board OPEN in the PCB editor the hole is created live through KiCad's protobuf API (commit:\"create-items\", one Undo step).", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2], LIVE_PITFALLS[3]], }, Verb { name: "kicad_import_svg",⋯ 33 unchanged lines ⋯ example: "kicad_add_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"libraryId\":\"Capacitor_SMD:C_0402_1005Metric\",\"x\":130,\"y\":90,\"reference\":\"C\",\"value\":\"100n\",\"count\":4,\"pitch\":[1.5,0],\"expectedRevision\":\"<rev>\"}", hint: "libraryId resolves through the project's fp-lib-table, then your global one (KiCad's stock libraries included). reference \"C\" takes the next free numbers; \"C12\" starts there and refuses a taken one (duplicate_reference names the next free). A back-side part is flipped the way KiCad flips (left/right), then turned to rotation. duplicateOf copies the part with fresh UUIDs, without its nets (keepNets:true keeps them) and without its schematic link. The reply lists each added ref with its uuid and pose.", related: &["kicad_move_footprint", "kicad_placement_state", "kicad_add_mounting_hole", "kicad_update_pcb_from_schematic"],- pitfalls: &["On a board OPEN in the PCB editor a footprint is created live (commit:\"create-items\", one Undo step) when the same library footprint is already on that side of the board with the same pads and graphics: KiCad's own encoding of it is copied. Otherwise KiCad 10.0.x cannot create it over its API: the verb refuses with live_create_unsupported, says why in liveCreateWhy and changes nothing; save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "A part added on the board is not in the schematic: Update PCB from Schematic would remove it unless the schematic gains the symbol.", "No courtyard check here: run kicad_placement_validate, or place with kicad_move_footprint's checks.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2]],+ pitfalls: &["On a board OPEN in the PCB editor a footprint is created live through KiCad's protobuf API (commit:\"create-items\", one Undo step): copied from KiCad's own encoding when the same library footprint is already on that side of the board, else built from the library's text. A footprint with custom-shape pads, mask or paste margins, or zones inside refuses with live_create_unsupported (liveCreateWhy says which) and changes nothing; save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "A part added on the board is not in the schematic: Update PCB from Schematic would remove it unless the schematic gains the symbol.", "No courtyard check here: run kicad_placement_validate, or place with kicad_move_footprint's checks.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2]], }, Verb { name: "kicad_flip_footprint",⋯ 741 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -0,0 +1,371 @@+//! Verb group "composite": EDA Bridge Standard jobs (native tier) that KiCad does as several of+//! this bridge's own steps. Each runs those verbs exactly as a caller would (verbs::call), so their+//! checks, refusals and read-backs apply unchanged:+//! - `kicad_compile`: ERC plus the netlist, the standard's compile;+//! - `kicad_list_installed_libraries`: every symbol and footprint library KiCad would load;+//! - `kicad_replicate_layout`: one channel's placement copied to matching channels, through+//! kicad_move_footprint (one Undo step, its courtyard and outline checks);+//! - `kicad_diff_pairs`: the differential pairs KiCad infers from net names, and the net class that+//! gives a pair its width and gap.+//!+//! Every verb here is catalogued as File: the dispatcher then takes no GUI_LOCK for it, and each step+//! takes the lock itself. A composite that held the lock would deadlock on its first live step.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs::call;++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_compile",+ summary: "Check a project's schematic the way a compile does: KiCad's ERC on the root sheet (hierarchy followed) and the netlist, with the messages counted by class and the net and part counts.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 300,+ input: "{\"projectPath\": \"C:/.../p.kicad_pro\" | \"filePath\": \"C:/.../root.kicad_sch\", \"limit\"?: 200}",+ example: "kicad_compile {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+ hint: "messages are ERC's violations (class, severity, text, where), the first limit of them; counts and byClass cover all. nets and parts come from the netlist; kicad_netlist {expect} compares it with what you meant.",+ related: &["kicad_run_erc", "kicad_netlist", "kicad_sch_check", "kicad_design_review"],+ pitfalls: &["Needs kicad-cli (KiCad 8 or newer) on this machine.", "Reads the saved files: unsaved edits in an open editor are not checked."],+ },+ Verb {+ name: "kicad_list_installed_libraries",+ summary: "Every symbol and footprint library KiCad loads: the global tables and, with projectPath, the project's own, with nickname, path and whether the path exists.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+ input: "{\"projectPath\"?: \"C:/.../p.kicad_pro\", \"kind\"?: \"symbol\"|\"footprint\"|\"both\"}",+ example: "kicad_list_installed_libraries {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+ hint: "scope is global or project; a project row of the same nickname wins over the global one in KiCad. skipped lists rows whose path uses a variable the bridge cannot resolve. kicad_install_library adds one; kicad_search_library searches them.",+ related: &["kicad_install_library", "kicad_search_library", "kicad_list_symbols", "kicad_list_footprints"],+ pitfalls: &["The tables are read from disk: a library KiCad added in a running session is listed once KiCad saved its table."],+ },+ Verb {+ name: "kicad_replicate_layout",+ summary: "Copy one channel's placement to matching channels: each target keeps its anchor part where it is, and every other part takes the source's offset and rotation from the anchor (turned by the anchors' rotation difference). One Undo step, with kicad_move_footprint's checks.",+ // File, not Ipc: a composite holds no GUI_LOCK itself; its steps (placement_state, move_footprint) take it in turn+ mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+ input: "{\"filePath\", \"expectedRevision\"?, \"source\": [\"U1\", \"C1\", \"R1\"], \"targets\": [[\"U2\", \"C2\", \"R2\"], ...] (each in the same order as source), \"anchor\"?: \"U1\" (default the first), \"dryRun\"?: false, \"allowOverlap\"?: false, \"allowOutside\"?: false, \"save\"?: false}",+ example: "kicad_replicate_layout {\"filePath\":\"C:/p/b.kicad_pcb\",\"source\":[\"U1\",\"C1\",\"C2\"],\"targets\":[[\"U2\",\"C3\",\"C4\"]],\"dryRun\":true}",+ hint: "Place the source channel and each target's anchor first; the rest follows. moves is the plan (kicad_move_footprint refs); dryRun checks courtyards and the outline without moving. Copy the routing after with kicad_copy_routing {refMap}.",+ related: &["kicad_copy_routing", "kicad_move_footprint", "kicad_placement_state", "kicad_align_parts"],+ pitfalls: &["Every part must be on the same side as its source counterpart (side changes are refused).", "Live when the PCB editor serves the board, else the closed file, like kicad_move_footprint."],+ },+ Verb {+ name: "kicad_diff_pairs",+ summary: "Differential pairs on the board: list the pairs KiCad's router recognises from net names (+/-, P/N, _P/_N), with the net class that sets their width and gap; define one (a net class holding both nets, with diffPairWidth and diffPairGap) or remove that class.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"name\"?: \"USB\" (omit to list), \"pos\"?: \"USB_D+\", \"neg\"?: \"USB_D-\", \"widthMm\"?: 0.2, \"gapMm\"?: 0.15, \"remove\"?: false, \"dryRun\"?: bool}",+ example: "kicad_diff_pairs {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"name\":\"USB\",\"pos\":\"USB_D+\",\"neg\":\"USB_D-\",\"widthMm\":0.2,\"gapMm\":0.15}",+ hint: "KiCad has no pair object: its router pairs two nets whose names differ only by the suffix (+/-, P/N, _P/_N), and the pair's width and gap come from their net class. define writes that class into the project; route the pair with kicad_route_pair.",+ related: &["kicad_route_pair", "kicad_netclasses", "kicad_list_nets"],+ pitfalls: &["Two nets whose names do not pair by suffix are refused (not_a_kicad_pair): rename them in the schematic first.", "The class is written to the .kicad_pro beside the board: an open PCB editor sees it after reloading the project."],+ },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_compile" => compile(state, args),+ "kicad_list_installed_libraries" => installed_libraries(state, args),+ "kicad_replicate_layout" => replicate_layout(state, args),+ "kicad_diff_pairs" => diff_pairs(state, args),+ _ => return None,+ })+}++fn root_sheet(args: &Value) -> Result<String, Value> {+ if let Some(f) = arg_str(args, "filePath").filter(|f| f.to_ascii_lowercase().ends_with(".kicad_sch")) {+ return Ok(f.to_string());+ }+ match arg_str(args, "projectPath") {+ Some(p) => Ok(norm(&std::path::PathBuf::from(p).with_extension("kicad_sch"))),+ None => Err(fail("missing_arg", "projectPath or filePath (the root .kicad_sch) is required", "Pass the project; its root sheet is the .kicad_sch beside the .kicad_pro.")),+ }+}++/// `kicad_compile`: ERC and the netlist, grouped the way a compile's messages are.+fn compile(state: &mut State, args: &Value) -> Value {+ let root = match root_sheet(args) {+ Ok(r) => r,+ Err(e) => return e,+ };+ let erc = call(state, "kicad_run_erc", &json!({"filePath": root}));+ if erc["success"] != true {+ return erc;+ }+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;+ let all = erc.pointer("/data/violations").and_then(Value::as_array).cloned().unwrap_or_default();+ let mut by_class: std::collections::BTreeMap<String, usize> = Default::default();+ let mut messages = Vec::new();+ for v in &all {+ let class = v.get("type").or_else(|| v.get("kind")).and_then(Value::as_str).unwrap_or("other").to_string();+ *by_class.entry(class.clone()).or_default() += 1;+ if messages.len() < limit {+ messages.push(json!({"class": class, "severity": v.get("severity"), "text": v.get("description").or_else(|| v.get("message")), "where": v.get("items").or_else(|| v.get("sheet"))}));+ }+ }+ let net = call(state, "kicad_netlist", &json!({"filePath": root}));+ let nets = net.get("nets").and_then(Value::as_object).map(|n| n.len());+ let errors = erc.pointer("/summary/errors").cloned().unwrap_or(json!(0));+ let warnings = erc.pointer("/summary/warnings").cloned().unwrap_or(json!(0));+ json!({+ "success": true,+ "output": format!("Compiled {root}: {errors} error(s), {warnings} warning(s), {} net(s)", nets.map(|n| n.to_string()).unwrap_or_else(|| "?".into())),+ "root": root,+ "counts": {"errors": errors, "warnings": warnings, "total": all.len()},+ "byClass": by_class,+ "messages": messages,+ "truncated": all.len() > messages.len(),+ "nets": nets,+ "parts": net.get("parts").and_then(Value::as_array).map(|p| p.len()).or_else(|| net.get("partCount").and_then(Value::as_u64).map(|n| n as usize)),+ "netlistError": if net["success"] == true { Value::Null } else { net.get("error").cloned().unwrap_or(Value::Null) },+ "clean": erc.pointer("/summary/clean").cloned().unwrap_or(json!(errors == json!(0))),+ "_hint": "Fix errors first (byClass names the kinds); kicad_sch_check explains connectivity problems on one sheet with points.",+ })+}++/// `kicad_list_installed_libraries`: the library tables KiCad reads.+fn installed_libraries(state: &mut State, args: &Value) -> Value {+ let kinds: &[&'static str] = match arg_str(args, "kind").unwrap_or("both") {+ "symbol" => &["symbol"],+ "footprint" => &["footprint"],+ "both" => &["symbol", "footprint"],+ other => return fail("bad_args", format!("kind must be symbol, footprint or both, not {other}"), "Leave kind out for both."),+ };+ let info = state.kicad_info();+ let ctx = crate::verbs_libraries::lib_ctx(&info);+ let project = arg_str(args, "projectPath").map(std::path::PathBuf::from).map(|p| if p.is_dir() { p } else { p.parent().map(|d| d.to_path_buf()).unwrap_or(p) });+ let mut rows = Vec::new();+ let mut skipped = Vec::new();+ for k in kinds {+ let (libs, skip) = kicad_core::libsearch::registered(&ctx, project.as_deref(), k);+ for l in libs {+ rows.push(json!({"kind": l.kind, "nickname": l.nickname, "path": norm(&l.path), "scope": l.scope, "exists": l.path.exists()}));+ }+ skipped.extend(skip);+ }+ let missing = rows.iter().filter(|r| r["exists"] == false).count();+ json!({"success": true, "libraries": rows, "skipped": skipped, "missing": missing,+ "output": format!("{} librar{} ({} missing on disk)", rows.len(), if rows.len() == 1 { "y" } else { "ies" }, missing),+ "_hint": "A library with exists:false is in a table but not on this machine: KiCad shows its parts as missing."})+}++/// `kicad_replicate_layout`: the moves that copy the source channel onto each target, through+/// kicad_move_footprint.+fn replicate_layout(state: &mut State, args: &Value) -> Value {+ let list = |v: Option<&Value>| -> Vec<String> { v.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default() };+ let source = list(args.get("source"));+ let targets: Vec<Vec<String>> = args.get("targets").and_then(Value::as_array).map(|a| a.iter().map(|t| list(Some(t))).collect()).unwrap_or_default();+ if source.len() < 2 || targets.is_empty() {+ return fail("missing_arg", "source (2 or more refs) and targets ([[refs], ...]) are required", "Each target lists its parts in the same order as source.");+ }+ if let Some(bad) = targets.iter().find(|t| t.len() != source.len()) {+ return fail("bad_args", format!("target {:?} has {} parts; source has {}", bad, bad.len(), source.len()), "Each target lists one part per source part, in the same order.");+ }+ let anchor = match arg_str(args, "anchor") {+ Some(a) => match source.iter().position(|s| s.eq_ignore_ascii_case(a)) {+ Some(i) => i,+ None => return fail("bad_args", format!("anchor {a} is not in source"), "Pass one of the source refs, or leave anchor out for the first."),+ },+ None => 0,+ };+ let Some(file) = arg_str(args, "filePath") else { return fail("missing_arg", "filePath is required", "The .kicad_pcb.") };+ let st = call(state, "kicad_placement_state", &json!({"filePath": file}));+ if st["success"] == false {+ return st;+ }+ let parts = st.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default();+ let find = |r: &str| parts.iter().find(|p| p["ref"].as_str().map(|x| x.eq_ignore_ascii_case(r)).unwrap_or(false)).cloned();+ let pose = |r: &str| -> Result<(f64, f64, f64, String), Value> {+ let p = find(r).ok_or_else(|| fail("part_not_found", format!("{r} is not on the board"), "kicad_placement_state lists the parts."))?;+ Ok((p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), p["rotation"].as_f64().unwrap_or(0.0), p["side"].as_str().unwrap_or("").to_string()))+ };+ let src: Vec<(f64, f64, f64, String)> = match source.iter().map(|r| pose(r)).collect() {+ Ok(v) => v,+ Err(e) => return e,+ };+ let a = &src[anchor];+ let mut moves = Vec::new();+ for t in &targets {+ let tp: Vec<(f64, f64, f64, String)> = match t.iter().map(|r| pose(r)).collect() {+ Ok(v) => v,+ Err(e) => return e,+ };+ let ta = &tp[anchor];+ let turn = ta.2 - a.2;+ for (i, r) in t.iter().enumerate() {+ if i == anchor {+ continue;+ }+ if tp[i].3 != src[i].3 {+ return fail("side_change_not_supported", format!("{r} is on {} but its source {} is on {}", tp[i].3, source[i], src[i].3), "Flip the target part to the source's side first (kicad_move_footprint side, or flip it in KiCad).");+ }+ let (x, y) = kicad_core::placement::to_board((ta.0, ta.1), turn, (src[i].0 - a.0, src[i].1 - a.1));+ let rot = (src[i].2 + turn).rem_euclid(360.0);+ moves.push(json!({"ref": r, "x": (x * 1e4).round() / 1e4, "y": (y * 1e4).round() / 1e4, "rotation": if rot > 180.0 { rot - 360.0 } else { rot }}));+ }+ }+ let mut m = json!({"filePath": file, "refs": moves});+ for k in ["expectedRevision", "dryRun", "allowOverlap", "allowOutside", "allowLocked", "save", "source"] {+ if let Some(v) = args.get(k).filter(|_| k != "source") {+ m[k] = v.clone();+ }+ }+ if m.get("expectedRevision").is_none() {+ if let Some(rev) = st.get("revision") {+ m["expectedRevision"] = rev.clone();+ }+ }+ let mut out = call(state, "kicad_move_footprint", &m);+ out["moves"] = json!(moves);+ out["anchor"] = json!(source[anchor]);+ out+}++/// The two names of a pair KiCad's router recognises: same base, suffixes +/-, P/N or _P/_N.+fn pair_base(net: &str) -> Option<(String, bool)> {+ for (p, n) in [("+", "-"), ("_P", "_N"), ("P", "N")] {+ if let Some(b) = net.strip_suffix(p) {+ return Some((format!("{b}|{p}{n}"), true));+ }+ if let Some(b) = net.strip_suffix(n) {+ return Some((format!("{b}|{p}{n}"), false));+ }+ }+ None+}++/// `kicad_diff_pairs`: list, define (a net class holding both nets) or remove.+fn diff_pairs(state: &mut State, args: &Value) -> Value {+ let Some(file) = arg_str(args, "filePath") else { return fail("missing_arg", "filePath is required", "The .kicad_pcb (its project holds the net classes).") };+ let nets_reply = call(state, "kicad_list_nets", &json!({"filePath": file, "source": args.get("source").cloned().unwrap_or(json!("auto"))}));+ if nets_reply["success"] != true {+ return nets_reply;+ }+ let nets: Vec<String> = nets_reply.get("nets").and_then(Value::as_array).map(|a| a.iter().filter_map(|n| n.as_str().map(String::from).or_else(|| n.get("name").and_then(Value::as_str).map(String::from))).collect()).unwrap_or_default();+ let classes = call(state, "kicad_netclasses", &json!({"filePath": file}));+ // each class lists the board nets that resolve to it+ let class_of = |n: &str| -> Option<String> {+ classes.get("classes").and_then(Value::as_array)?.iter()+ .find(|c| c["nets"].as_array().map(|a| a.iter().any(|x| x.as_str() == Some(n))).unwrap_or(false))+ .and_then(|c| c["name"].as_str().map(String::from))+ };+ let name = arg_str(args, "name");+ if name.is_none() {+ let mut groups: std::collections::BTreeMap<String, (Option<String>, Option<String>)> = Default::default();+ for n in &nets {+ if let Some((key, pos)) = pair_base(n) {+ let e = groups.entry(key).or_default();+ if pos { e.0 = Some(n.clone()) } else { e.1 = Some(n.clone()) }+ }+ }+ let pairs: Vec<Value> = groups.into_iter().filter_map(|(k, (p, n))| {+ let (p, n) = (p?, n?);+ let base = k.split('|').next().unwrap_or("").trim_end_matches('_').to_string();+ let (cp, cn) = (class_of(&p), class_of(&n));+ Some(json!({"name": base, "pos": p, "neg": n, "netclass": if cp == cn { cp } else { None }, "netclasses": [class_of(&p), class_of(&n)]}))+ }).collect();+ return json!({"success": true, "pairs": pairs, "output": format!("{} differential pair(s)", pairs.len()),+ "_hint": "These are the pairs KiCad's router recognises by name. A pair's width and gap come from its net class (diffPairWidth, diffPairGap); define one with name, pos and neg."});+ }+ let name = name.unwrap_or_default();+ if args.get("remove").and_then(Value::as_bool).unwrap_or(false) {+ return call(state, "kicad_netclasses", &json!({"filePath": file, "remove": name, "dryRun": args.get("dryRun").cloned().unwrap_or(json!(false))}));+ }+ let (Some(pos), Some(neg)) = (arg_str(args, "pos"), arg_str(args, "neg")) else {+ return fail("missing_arg", "pos and neg are required to define a pair", "Pass the positive and negative nets, e.g. USB_D+ and USB_D-.");+ };+ for n in [pos, neg] {+ if !nets.iter().any(|x| x == n) {+ return fail("no_such_net", format!("{n} is not a net on this board"), "kicad_list_nets lists them, spelled exactly.");+ }+ }+ match (pair_base(pos), pair_base(neg)) {+ (Some((a, true)), Some((b, false))) if a == b => {}+ _ => return fail("not_a_kicad_pair", format!("{pos} and {neg} do not pair by name: KiCad's router pairs nets that differ only by +/-, P/N or _P/_N"), "Rename the nets in the schematic (USB_D+ / USB_D-), update the board from the schematic, then define the pair."),+ }+ let mut set = json!({"name": name});+ if let Some(w) = args.get("widthMm") {+ set["diffPairWidth"] = w.clone();+ }+ if let Some(g) = args.get("gapMm") {+ set["diffPairGap"] = g.clone();+ }+ let mut out = call(state, "kicad_netclasses", &json!({"filePath": file, "set": set, "assign": {"netclass": name, "patterns": [pos, neg]}, "dryRun": args.get("dryRun").cloned().unwrap_or(json!(false))}));+ if out["success"] == true {+ out["pair"] = json!({"name": name, "pos": pos, "neg": neg});+ out["_hint"] = json!("The pair's class is written. Route it with kicad_route_pair; kicad_diff_pairs lists it with its class.");+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn pairs_by_kicads_suffixes() {+ assert_eq!(pair_base("USB_D+"), Some(("USB_D|+-".into(), true)));+ assert_eq!(pair_base("USB_D-"), Some(("USB_D|+-".into(), false)));+ assert_eq!(pair_base("LVDS_P").map(|x| x.0), pair_base("LVDS_N").map(|x| x.0));+ assert_eq!(pair_base("GND"), None);+ }++ fn fixture_copy() -> std::path::PathBuf {+ let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");+ let d = std::env::temp_dir().join(format!("kicad-bridge-composite-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+ std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();+ for e in std::fs::read_dir(&src).unwrap().flatten() {+ if e.path().is_file() {+ std::fs::copy(e.path(), d.join(e.file_name())).unwrap();+ }+ }+ for e in std::fs::read_dir(src.join("Fixture.pretty")).unwrap().flatten() {+ std::fs::copy(e.path(), d.join("Fixture.pretty").join(e.file_name())).unwrap();+ }+ d+ }++ #[test]+ fn a_channel_is_replicated_from_the_target_anchor() {+ let d = fixture_copy();+ let b = norm(&d.join("route-fixture.kicad_pcb"));+ let v = dispatch(&mut State::new(), "kicad_replicate_layout", &json!({"filePath": b, "source": ["C1", "C2"], "targets": [["R1", "U2"]], "dryRun": true, "allowOverlap": true, "allowOutside": true})).unwrap();+ // C2 sits 4 mm right of C1; R1 is the target anchor at (50, 34), so U2 goes to (54, 34)+ assert_eq!(v["moves"], json!([{"ref": "U2", "x": 54.0, "y": 34.0, "rotation": 0.0}]), "{v}");+ assert_eq!(v["success"], true, "{v}");+ let e = dispatch(&mut State::new(), "kicad_replicate_layout", &json!({"filePath": b, "source": ["C1", "C2"], "targets": [["R1"]]})).unwrap();+ assert_eq!(e["errorCode"], "bad_args");+ }++ #[test]+ fn the_project_library_table_is_listed() {+ let d = fixture_copy();+ let v = dispatch(&mut State::new(), "kicad_list_installed_libraries", &json!({"projectPath": norm(&d.join("route-fixture.kicad_pro")), "kind": "footprint"})).unwrap();+ assert_eq!(v["success"], true, "{v}");+ let row = v["libraries"].as_array().unwrap().iter().find(|r| r["nickname"] == "Fixture").cloned().unwrap_or_else(|| panic!("{v}"));+ assert_eq!(row["scope"], "project");+ assert_eq!(row["exists"], true);+ }++ #[test]+ fn usb_pairs_by_name_and_takes_a_class() {+ let d = fixture_copy();+ let b = norm(&d.join("route-fixture.kicad_pcb"));+ let v = dispatch(&mut State::new(), "kicad_diff_pairs", &json!({"filePath": b, "source": "file"})).unwrap();+ assert_eq!(v["success"], true, "{v}");+ assert!(v["pairs"].as_array().unwrap().iter().any(|p| p["pos"] == "USB_D+" && p["neg"] == "USB_D-"), "{v}");+ let v = dispatch(&mut State::new(), "kicad_diff_pairs", &json!({"filePath": b, "source": "file", "name": "USB", "pos": "USB_D+", "neg": "USB_D-", "widthMm": 0.2, "gapMm": 0.15})).unwrap();+ assert_eq!(v["success"], true, "{v}");+ let v = dispatch(&mut State::new(), "kicad_diff_pairs", &json!({"filePath": b, "source": "file"})).unwrap();+ assert!(v["pairs"].as_array().unwrap().iter().any(|p| p["netclass"] == "USB"), "{v}");+ let e = dispatch(&mut State::new(), "kicad_diff_pairs", &json!({"filePath": b, "source": "file", "name": "X", "pos": "+3V3", "neg": "USB_D-"})).unwrap();+ assert_eq!(e["errorCode"], "not_a_kicad_pair", "{e}");+ }++ #[test]+ fn composites_hold_no_gui_lock_of_their_own() {+ for v in VERBS {+ assert!(matches!(v.mechanism, Mechanism::File), "{} would take GUI_LOCK and deadlock on its own live steps", v.name);+ }+ }+}+@@ -1,760 +1,761 @@⋯ 144 unchanged lines ⋯ .map_err(|e| { if e.message.contains(LIVE_CREATE_UNSUPPORTED) { let why = text_path_why.borrow().clone();- let e = if why.is_empty() { e } else { e.with("liveCreateWhy", json!(format!("Not created through KiCad's protobuf API: {why}. The bridge creates a footprint live by copying KiCad's own encoding of the same library footprint already on the board (same side, same pads and graphics); board graphics are always created live."))) };+ let e = if why.is_empty() { e } else { e.with("liveCreateWhy", json!(format!("Not created through KiCad's protobuf API: {why}. The bridge creates a footprint live by copying KiCad's own encoding of the same library footprint on the board, else by building it from the library text; it refuses custom-shape pads, mask or paste margins and zones inside a footprint."))) }; let mut e = e.with("_hint", json!("KiCad's API does not create board items from text yet (ParseAndCreateItemsFromString answers empty on KiCad 10.0.4 and 10.0.7). Nothing was changed. Save the board in KiCad and close it (kicad_save_board, then kicad_close), then call this verb again: with no editor holding the board, the bridge edits the closed file (a backup is kept) and KiCad shows the change when the board is reopened.")) .with("kicadVersion", json!(s.version.full_version)).with("mutated", json!(false)); e.code = LIVE_CREATE_UNSUPPORTED.into();⋯ 606 unchanged lines ⋯ assert_eq!(edit_footprint_proto(&mut item, &json!({"newReference": "R9"})).unwrap_err().code, "ipc_error"); } }+@@ -1,472 +1,904 @@⋯ 373 unchanged lines ⋯ Ok(fp) } +// -- footprints built from their own text (no copy on the board needed) ------------------------------++const CU_ALL: &[&str] = &["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "In5.Cu", "In6.Cu", "In7.Cu", "In8.Cu", "In9.Cu", "In10.Cu", "In11.Cu", "In12.Cu", "In13.Cu", "In14.Cu", "In15.Cu", "In16.Cu", "In17.Cu", "In18.Cu", "In19.Cu", "In20.Cu", "In21.Cu", "In22.Cu", "In23.Cu", "In24.Cu", "In25.Cu", "In26.Cu", "In27.Cu", "In28.Cu", "In29.Cu", "In30.Cu", "B.Cu"];++/// A pad's `(layers ..)` as API layer ids, wildcards expanded the way KiCad stores them+/// (`*.Cu` is every copper layer, F, In1..In30, B).+fn pad_layers(n: &Node) -> Result<Vec<i32>, String> {+ let mut names: Vec<String> = Vec::new();+ for l in n.child("layers").map(|l| l.children().iter().skip(1).filter_map(|c| c.value()).collect::<Vec<_>>()).unwrap_or_default() {+ match l.as_str() {+ "*.Cu" => names.extend(CU_ALL.iter().map(|s| s.to_string())),+ "F&B.Cu" => names.extend(["F.Cu".to_string(), "B.Cu".to_string()]),+ w if w.starts_with("*.") => {+ let suf = &w[2..];+ names.extend([format!("F.{suf}"), format!("B.{suf}")]);+ }+ other => names.push(other.to_string()),+ }+ }+ let mut ids = Vec::new();+ for n in names {+ let id = BoardLayerInfo::id_from_name(&n).ok_or_else(|| format!("unknown pad layer {n}"))?;+ if !ids.contains(&id) {+ ids.push(id);+ }+ }+ Ok(ids)+}++fn mm(n: &Node, tag: &str, i: usize) -> Option<f64> {+ n.child(tag).and_then(|c| c.num(i))+}++/// One `(pad ..)` of a placed footprint as KiCad's Pad item, at board coordinates.+fn pad_item(n: &Node, origin: (f64, f64), rot: f64) -> Result<prost_types::Any, String> {+ for unsupported in ["primitives", "solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", "thermal_width", "thermal_gap", "thermal_bridge_width", "padstack", "teardrops"] {+ if n.child(unsupported).is_some() {+ return Err(format!("pad {} carries {unsupported}", n.arg(0).unwrap_or_default()));+ }+ }+ let kind = n.arg(1).unwrap_or_default();+ let shape = n.arg(2).unwrap_or_default();+ let at = n.child("at").ok_or("pad without (at ..)")?;+ let local = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0));+ let angle = at.num(2).unwrap_or(0.0);+ let (x, y) = crate::placement::to_board(origin, rot, local);+ let mut pad = kicad_ipc_rs::PadItem::from_proto(Default::default());+ let p = pad.proto_mut();+ p.id = Some(Default::default());+ if let Some(k) = p.id.as_mut() {+ k.value = n.child_value("uuid").unwrap_or_else(new_uuid);+ }+ p.locked = 1;+ p.number = n.arg(0).unwrap_or_default();+ p.r#type = match kind.as_str() {+ "thru_hole" => 1,+ "smd" => 2,+ "connect" => 3,+ "np_thru_hole" => 4,+ other => return Err(format!("pad type {other}")),+ };+ p.net = Some(Default::default());+ if let (Some(net), Some(nn)) = (n.child("net"), p.net.as_mut()) {+ nn.name = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()).unwrap_or_default();+ }+ p.position = Some(Default::default());+ if let Some(v) = p.position.as_mut() {+ v.x_nm = nm(x);+ v.y_nm = nm(y);+ }+ if let Some(c) = mm(n, "clearance", 0) {+ p.copper_clearance_override = Some(Default::default());+ if let Some(d) = p.copper_clearance_override.as_mut() {+ d.value_nm = nm(c);+ }+ }+ p.pad_to_die_length = Some(Default::default());+ if let Some(d) = p.pad_to_die_length.as_mut() {+ d.value_nm = nm(mm(n, "die_length", 0).unwrap_or(0.0));+ }+ p.symbol_pin = Some(Default::default());+ if let Some(sp) = p.symbol_pin.as_mut() {+ sp.name = n.child_value("pinfunction").unwrap_or_default();+ }+ p.pad_stack = Some(Default::default());+ let ps = p.pad_stack.as_mut().ok_or("pad stack")?;+ ps.r#type = 1;+ ps.layers = pad_layers(n)?;+ ps.unconnected_layer_removal = 1;+ ps.angle = Some(Default::default());+ if let Some(a) = ps.angle.as_mut() {+ a.value_degrees = angle;+ }+ let (f_cu, b_cu) = (BoardLayerInfo::id_from_name("F.Cu").unwrap_or(3), BoardLayerInfo::id_from_name("B.Cu").unwrap_or(34));+ ps.drill = Some(Default::default());+ if let Some(d) = ps.drill.as_mut() {+ d.start_layer = f_cu;+ d.end_layer = b_cu;+ d.diameter = Some(Default::default());+ d.shape = 1;+ if let (Some(dr), Some(dia)) = (n.child("drill"), d.diameter.as_mut()) {+ if dr.arg(0).as_deref() == Some("oval") {+ d.shape = 2;+ dia.x_nm = nm(dr.num(1).unwrap_or(0.0));+ dia.y_nm = nm(dr.num(2).or(dr.num(1)).unwrap_or(0.0));+ } else {+ let v = dr.num(0).unwrap_or(0.0);+ dia.x_nm = nm(v);+ dia.y_nm = nm(v);+ }+ }+ }+ ps.copper_layers.push(Default::default());+ let cl = ps.copper_layers.last_mut().ok_or("copper layer")?;+ cl.layer = f_cu;+ let chamfered = n.child("chamfer").is_some();+ cl.shape = match shape.as_str() {+ "circle" => 1,+ "rect" => 2,+ "oval" => 3,+ "trapezoid" => 4,+ "roundrect" if chamfered => 6,+ "roundrect" => 5,+ other => return Err(format!("pad shape {other}")),+ };+ cl.size = Some(Default::default());+ if let Some(v) = cl.size.as_mut() {+ v.x_nm = nm(mm(n, "size", 0).unwrap_or(0.0));+ v.y_nm = nm(mm(n, "size", 1).unwrap_or(0.0));+ }+ cl.corner_rounding_ratio = mm(n, "roundrect_rratio", 0).unwrap_or(0.25);+ cl.chamfer_ratio = mm(n, "chamfer_ratio", 0).unwrap_or(0.2);+ if let Some(ch) = n.child("chamfer") {+ let corners: Vec<String> = ch.children().iter().skip(1).filter_map(|c| c.value()).collect();+ cl.chamfered_corners = Some(Default::default());+ if let Some(c) = cl.chamfered_corners.as_mut() {+ c.top_left = corners.iter().any(|x| x == "top_left");+ c.top_right = corners.iter().any(|x| x == "top_right");+ c.bottom_left = corners.iter().any(|x| x == "bottom_left");+ c.bottom_right = corners.iter().any(|x| x == "bottom_right");+ }+ }+ cl.custom_anchor_shape = 1;+ cl.offset = Some(Default::default());+ if let (Some(off), Some(v)) = (n.child("drill").and_then(|d| d.child("offset")), cl.offset.as_mut()) {+ v.x_nm = nm(off.num(0).unwrap_or(0.0));+ v.y_nm = nm(off.num(1).unwrap_or(0.0));+ }+ if let Some(rd) = n.child("rect_delta") {+ cl.trapezoid_delta = Some(Default::default());+ if let Some(v) = cl.trapezoid_delta.as_mut() {+ v.x_nm = nm(rd.num(0).unwrap_or(0.0));+ v.y_nm = nm(rd.num(1).unwrap_or(0.0));+ }+ }+ ps.zone_settings = Some(Default::default());+ if let Some(z) = ps.zone_settings.as_mut() {+ // (zone_connect 0 none | 1 thermal | 2 full | 3 thermal on PTH only), else the parent's+ z.zone_connection = match n.child("zone_connect").and_then(|c| c.num(0)).map(|v| v as i64) {+ Some(0) => 2,+ Some(1) => 3,+ Some(2) => 4,+ Some(3) => 5,+ _ => 1,+ };+ z.thermal_spokes = Some(Default::default());+ if let Some(t) = z.thermal_spokes.as_mut() {+ t.angle = Some(Default::default());+ if let Some(a) = t.angle.as_mut() {+ a.value_degrees = mm(n, "thermal_bridge_angle", 0).unwrap_or(if matches!(shape.as_str(), "circle" | "oval") { 45.0 } else { 90.0 });+ }+ }+ }+ Ok(EditablePcbItem::Pad(pad).into_any())+}++fn set_text(t: &mut kicad_ipc_rs::BoardTextItem, n: &Node, value: &str, at: (f64, f64), angle: f64, layer: i32) {+ let p = t.proto_mut();+ p.id = Some(Default::default());+ if let Some(k) = p.id.as_mut() {+ k.value = n.child_value("uuid").unwrap_or_else(new_uuid);+ }+ p.layer = layer;+ p.locked = 1;+ p.knockout = n.child("layer").map(|l| l.children().iter().any(|c| c.raw() == Some("knockout"))).unwrap_or(false);+ p.text = Some(Default::default());+ let Some(tx) = p.text.as_mut() else { return };+ tx.text = value.to_string();+ tx.position = Some(Default::default());+ if let Some(v) = tx.position.as_mut() {+ v.x_nm = nm(at.0);+ v.y_nm = nm(at.1);+ }+ tx.attributes = Some(Default::default());+ let Some(a) = tx.attributes.as_mut() else { return };+ let effects = n.child("effects");+ let font = effects.and_then(|e| e.child("font"));+ let size = font.and_then(|f| f.child("size"));+ a.size = Some(Default::default());+ if let Some(v) = a.size.as_mut() {+ // (size height width)+ v.y_nm = nm(size.and_then(|s| s.num(0)).unwrap_or(1.0));+ v.x_nm = nm(size.and_then(|s| s.num(1)).unwrap_or(1.0));+ }+ a.stroke_width = Some(Default::default());+ if let Some(d) = a.stroke_width.as_mut() {+ d.value_nm = nm(font.and_then(|f| f.child("thickness")).and_then(|t| t.num(0)).unwrap_or(0.15));+ }+ a.bold = font.and_then(|f| f.child_value("bold")).as_deref() == Some("yes");+ a.italic = font.and_then(|f| f.child_value("italic")).as_deref() == Some("yes");+ let justify: Vec<String> = effects.and_then(|e| e.child("justify")).map(|j| j.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();+ a.horizontal_alignment = if justify.iter().any(|j| j == "left") { 1 } else if justify.iter().any(|j| j == "right") { 3 } else { 2 };+ a.vertical_alignment = if justify.iter().any(|j| j == "top") { 1 } else if justify.iter().any(|j| j == "bottom") { 3 } else { 2 };+ a.mirrored = justify.iter().any(|j| j == "mirror");+ a.angle = Some(Default::default());+ if let Some(an) = a.angle.as_mut() {+ an.value_degrees = angle;+ }+ a.line_spacing = 1.0;+ a.visible = true;+ a.multiline = true;+ a.keep_upright = true;+}++/// `fp_*` graphic of a placed footprint as the board graphic KiCad's API takes (board coordinates).+fn fp_graphic(n: &Node, origin: (f64, f64), rot: f64, layer_id: &dyn Fn(&str) -> Option<i32>) -> Result<Vec<u8>, String> {+ let head = n.head().unwrap_or("").replacen("fp_", "gr_", 1);+ let xf = |p: (f64, f64)| crate::placement::to_board(origin, rot, p);+ let pt = |tag: &str| -> Option<(f64, f64)> { let c = n.child(tag)?; Some((c.num(0)?, c.num(1)?)) };+ let fmt = |p: (f64, f64)| format!("{} {}", p.0, p.1);+ let tail = format!("(stroke (width {}) (type {})) (fill {}) (layer \"{}\"){}",+ stroke_width(n), n.child("stroke").and_then(|s| s.child_value("type")).unwrap_or_else(|| "solid".into()),+ if filled(n) { "yes" } else { "no" }, n.child_value("layer").unwrap_or_default(),+ n.child_value("uuid").map(|u| format!(" (uuid \"{u}\")")).unwrap_or_default());+ let text = match head.as_str() {+ "gr_line" => format!("(gr_line (start {}) (end {}) {tail})", fmt(xf(pt("start").ok_or("start")?)), fmt(xf(pt("end").ok_or("end")?))),+ "gr_arc" => format!("(gr_arc (start {}) (mid {}) (end {}) {tail})", fmt(xf(pt("start").ok_or("start")?)), fmt(xf(pt("mid").ok_or("mid")?)), fmt(xf(pt("end").ok_or("end")?))),+ "gr_circle" => format!("(gr_circle (center {}) (end {}) {tail})", fmt(xf(pt("center").ok_or("center")?)), fmt(xf(pt("end").ok_or("end")?))),+ "gr_rect" => {+ let (a, b) = (pt("start").ok_or("start")?, pt("end").ok_or("end")?);+ let corners = [a, (b.0, a.1), b, (a.0, b.1)].map(xf);+ if (rot.rem_euclid(90.0)).abs() < 1e-9 {+ // the library's own two corners, turned: KiCad turns them back to exactly the library's+ format!("(gr_rect (start {}) (end {}) {tail})", fmt(xf(a)), fmt(xf(b)))+ } else {+ format!("(gr_poly (pts {}) {tail})", corners.iter().map(|c| format!("(xy {})", fmt(*c))).collect::<Vec<_>>().join(" "))+ }+ }+ "gr_poly" => {+ let pts = n.child("pts").ok_or("pts")?;+ let mut out = Vec::new();+ for c in pts.children().iter().skip(1) {+ match c.head() {+ Some("xy") => out.push(format!("(xy {})", fmt(xf((c.num(0).unwrap_or(0.0), c.num(1).unwrap_or(0.0)))))),+ Some("arc") => {+ let g = |t: &str| c.child(t).and_then(|x| Some((x.num(0)?, x.num(1)?))).map(xf).map(fmt).unwrap_or_default();+ out.push(format!("(arc (start {}) (mid {}) (end {}))", g("start"), g("mid"), g("end")));+ }+ _ => {}+ }+ }+ format!("(gr_poly (pts {}) {tail})", out.join(" "))+ }+ other => return Err(format!("{} cannot be encoded", other.replacen("gr_", "fp_", 1))),+ };+ graphic_bytes(&parse_node(&text)?, layer_id)+}++/// A `(model ..)` as `kiapi.board.types.Footprint3DModel` bytes (mm offset, degrees, scale).+fn model_bytes(n: &Node) -> Vec<u8> {+ let xyz = |tag: &str, default: f64| -> Vec<u8> {+ let c = n.child(tag).and_then(|t| t.child("xyz"));+ let mut b = Vec::new();+ for (i, f) in [1u32, 2, 3].iter().enumerate() {+ let v = c.and_then(|c| c.num(i)).unwrap_or(default);+ if v != 0.0 {+ put_varint(&mut b, ((*f as u64) << 3) | 1);+ b.extend_from_slice(&v.to_le_bytes());+ }+ }+ b+ };+ let mut out = Vec::new();+ wire_message(&mut out, 1, n.arg(0).unwrap_or_default().as_bytes());+ wire_message(&mut out, 2, &xyz("scale", 1.0));+ wire_message(&mut out, 3, &xyz("rotate", 0.0));+ wire_message(&mut out, 4, &xyz("offset", 0.0));+ wire_varint(&mut out, 5, if n.child_value("hide").as_deref() == Some("yes") { 0 } else { 1 });+ let opacity = n.child("opacity").and_then(|o| o.num(0)).unwrap_or(1.0);+ put_varint(&mut out, (6 << 3) | 1);+ out.extend_from_slice(&opacity.to_le_bytes());+ out+}++/// A footprint built from its own text (the block the closed-board edit would write), for a board+/// that has no copy of it. Refuses, so the edit keeps the text path, anything it cannot encode+/// faithfully: custom pad shapes, mask and paste margins, zones and groups inside the footprint.+pub fn build_footprint(block: &Node) -> Result<FootprintItem, String> {+ let layer_id = |name: &str| BoardLayerInfo::id_from_name(name);+ let at = block.child("at").ok_or("(at ..) missing")?;+ let (x, y, rot) = (at.num(0).ok_or("bad x")?, at.num(1).ok_or("bad y")?, at.num(2).unwrap_or(0.0));+ let side = block.child_value("layer").unwrap_or_else(|| "F.Cu".into());+ let (lib, name) = lib_id(block).ok_or("the footprint names no library footprint")?;+ let mut fp = FootprintItem::from_proto(Default::default());+ let mut items: Vec<prost_types::Any> = Vec::new();+ {+ let p = fp.proto_mut();+ p.id = Some(Default::default());+ if let Some(k) = p.id.as_mut() {+ k.value = block.child_value("uuid").unwrap_or_else(new_uuid);+ }+ p.position = Some(Default::default());+ if let Some(v) = p.position.as_mut() {+ v.x_nm = nm(x);+ v.y_nm = nm(y);+ }+ p.orientation = Some(Default::default());+ if let Some(o) = p.orientation.as_mut() {+ o.value_degrees = rot;+ }+ p.layer = layer_id(&side).ok_or_else(|| format!("unknown side {side}"))?;+ p.locked = if block.child("locked").is_some() { 2 } else { 1 };+ p.definition = Some(Default::default());+ let d = p.definition.as_mut().ok_or("definition")?;+ d.id = Some(Default::default());+ if let Some(i) = d.id.as_mut() {+ i.library_nickname = lib;+ i.entry_name = name;+ }+ let attr: Vec<String> = block.child("attr").map(|a| a.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();+ d.attributes = Some(Default::default());+ if let Some(a) = d.attributes.as_mut() {+ a.description = block.child_value("descr").unwrap_or_default();+ a.keywords = block.child_value("tags").unwrap_or_default();+ a.not_in_schematic = attr.iter().any(|x| x == "board_only");+ a.exclude_from_position_files = attr.iter().any(|x| x == "exclude_from_pos_files");+ a.exclude_from_bill_of_materials = attr.iter().any(|x| x == "exclude_from_bom");+ a.exempt_from_courtyard_requirement = attr.iter().any(|x| x == "allow_missing_courtyard");+ a.do_not_populate = attr.iter().any(|x| x == "dnp");+ a.allow_soldermask_bridges = attr.iter().any(|x| x == "allow_soldermask_bridges");+ a.mounting_style = if attr.iter().any(|x| x == "smd") { 2 } else if attr.iter().any(|x| x == "through_hole") { 1 } else { 3 };+ }+ p.attributes = d.attributes.clone();+ }+ for c in block.children().iter().skip(1) {+ match c.head() {+ Some("property") => {+ let key = c.arg(0).unwrap_or_default();+ let value = c.arg(1).unwrap_or_default();+ let fat = c.child("at");+ let local = (fat.and_then(|a| a.num(0)).unwrap_or(0.0), fat.and_then(|a| a.num(1)).unwrap_or(0.0));+ let pos = crate::placement::to_board((x, y), rot, local);+ let lay = layer_id(&c.child_value("layer").unwrap_or_else(|| "F.Fab".into())).ok_or("unknown field layer")?;+ let mut bt = kicad_ipc_rs::BoardTextItem::from_proto(Default::default());+ set_text(&mut bt, c, &value, pos, fat.and_then(|a| a.num(2)).unwrap_or(0.0), lay);+ let hidden = c.child_value("hide").as_deref() == Some("yes");+ let fid = match key.as_str() { "Reference" => 1, "Value" => 2, "Footprint" => 3, "Datasheet" => 4, "Description" => 5, _ => 0 };+ let mut field = kicad_ipc_rs::FieldItem::from_proto(Default::default());+ {+ let f = field.proto_mut();+ f.id = Some(Default::default());+ if let Some(i) = f.id.as_mut() {+ i.id = fid;+ }+ f.name = key.clone();+ f.text = Some(bt.into_proto());+ f.visible = !hidden;+ }+ let p = fp.proto_mut();+ match fid {+ 1 => p.reference_field = Some(field.into_proto()),+ 2 => p.value_field = Some(field.into_proto()),+ 4 => p.datasheet_field = Some(field.into_proto()),+ 5 => p.description_field = Some(field.into_proto()),+ 3 => {}+ _ => items.push(EditablePcbItem::Field(field).into_any()),+ }+ }+ Some("pad") => items.push(pad_item(c, (x, y), rot)?),+ Some(h) if h.starts_with("fp_") && h != "fp_text" && h != "fp_text_box" => {+ items.push(prost_types::Any { type_url: GRAPHIC_TYPE_URL.into(), value: fp_graphic(c, (x, y), rot, &layer_id)? });+ }+ Some("fp_text") => {+ let fat = c.child("at");+ let local = (fat.and_then(|a| a.num(0)).unwrap_or(0.0), fat.and_then(|a| a.num(1)).unwrap_or(0.0));+ let pos = crate::placement::to_board((x, y), rot, local);+ let lay = layer_id(&c.child_value("layer").unwrap_or_default()).ok_or("unknown text layer")?;+ let mut bt = kicad_ipc_rs::BoardTextItem::from_proto(Default::default());+ set_text(&mut bt, c, &c.arg(1).unwrap_or_default(), pos, fat.and_then(|a| a.num(2)).unwrap_or(0.0), lay);+ items.push(EditablePcbItem::BoardText(bt).into_any());+ }+ Some("model") => items.push(prost_types::Any { type_url: "type.googleapis.com/kiapi.board.types.Footprint3DModel".into(), value: model_bytes(c) }),+ Some(h @ ("zone" | "group" | "fp_text_box" | "dimension" | "net_tie_pad_groups" | "private_layers" | "embedded_files" | "component_classes")) => {+ return Err(format!("the footprint carries a {h} the bridge cannot build through KiCad's API yet"));+ }+ _ => {}+ }+ }+ if let Some(d) = fp.proto_mut().definition.as_mut() {+ d.items = items;+ }+ Ok(fp)+}+ /// Every block of a change as `CreateItems` payloads, or None when any block is of a kind this /// module does not encode (the caller then keeps the text path for the whole change). /// `template` finds a footprint on the board with the block's library id on the same side.⋯ 8 unchanged lines ⋯ Err(e) => return Encoded::Failed(e), }, Some("footprint") => {- let t = match template(&n) {- Ok(t) => t,- Err(why) => return Encoded::TextPath(why),+ let fp = match template(&n) {+ Ok(t) => clone_footprint(&t, &n),+ // no copy on the board: build it from its own text+ Err(why) => match build_footprint(&n) {+ Ok(fp) => Ok(fp),+ Err(e) => return Encoded::TextPath(format!("{why}, and it cannot be built from its text ({e})")),+ }, };- match clone_footprint(&t, &n) {+ match fp { Ok(fp) => out.push(EditablePcbItem::Footprint(fp).into_any()), Err(e) => return Encoded::Failed(e), }⋯ 49 unchanged lines ⋯ assert!(anys.iter().all(|a| a.type_url == GRAPHIC_TYPE_URL)); let mut with_fp = blocks.clone(); with_fp.push("(footprint \"X:Y\" (at 1 1) (layer \"F.Cu\"))".into());- assert!(matches!(encode_blocks(&with_fp, &|_| Err("none".into())), Encoded::TextPath(_)), "a footprint the board does not have keeps the text path");+ assert!(matches!(encode_blocks(&with_fp, &|_| Err("none".into())), Encoded::Items(_)), "a footprint the board does not have is built from its text");+ let mut with_zone = blocks.clone();+ with_zone.push("(footprint \"X:Y\" (at 1 1) (layer \"F.Cu\") (zone (net 0)))".into());+ assert!(matches!(encode_blocks(&with_zone, &|_| Err("none".into())), Encoded::TextPath(_)), "a footprint with a zone keeps the text path"); } #[test]⋯ 14 unchanged lines ⋯ let c = crate::board_edit::add_footprint(&board, Some((&bigger, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "reference": "R"})).unwrap(); assert_ne!(definition_signature(&parse_node(c.create[0].trim()).unwrap()), definition_signature(r1)); }++ #[test]+ fn a_library_footprint_builds_with_pads_at_board_positions() {+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route");+ let board = std::fs::read_to_string(dir.join("route-fixture.kicad_pcb")).unwrap();+ let lib = std::fs::read_to_string(dir.join("Fixture.pretty/R_0603_1608Metric.kicad_mod")).unwrap();+ let c = crate::board_edit::add_footprint(&board, Some((&lib, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "rotation": 90, "reference": "R", "value": "4k7"})).unwrap();+ let block = parse_node(c.create[0].trim()).unwrap();+ let fp = build_footprint(&block).unwrap();+ let p = fp.proto();+ assert_eq!(p.position.as_ref().map(|v| (v.x_nm, v.y_nm)), Some((40_000_000, 30_000_000)));+ assert_eq!(p.value_field.as_ref().and_then(|f| f.text.as_ref()).and_then(|t| t.text.as_ref()).map(|t| t.text.clone()), Some("4k7".into()));+ let pads: Vec<_> = p.definition.as_ref().unwrap().items.iter().filter(|a| a.type_url.ends_with(".Pad")).map(|a| EditablePcbItem::from_any(a.clone()).unwrap()).collect();+ assert_eq!(pads.len(), 2);+ // pad 1 sits at local (-0.825, 0): turned 90 degrees about (40, 30) it lands at (40, 30.825)+ let pos: Vec<(i64, i64)> = pads.iter().filter_map(|e| if let EditablePcbItem::Pad(pd) = e { pd.proto().position.as_ref().map(|v| (v.x_nm, v.y_nm)) } else { None }).collect();+ assert!(pos.contains(&(40_000_000, 30_825_000)) && pos.contains(&(40_000_000, 29_175_000)), "{pos:?}");+ let ids: std::collections::HashSet<String> = pads.iter().filter_map(|e| e.id().map(String::from)).collect();+ assert_eq!(ids.len(), 2, "every pad has its own id");+ } }+@@ -1,1495 +1,1512 @@⋯ 188 unchanged lines ⋯ # ---- phase 9: the standard's core verbs added in 1.0.64, run in order by std_phase() on scratch files **{v: dict(phase=9, scenario=True) for v in ("copy_footprint", "rename_footprint", "xcopy_footprint", "remove_footprint", "add_body", "clone_symbol", "rename_symbol", "set_params", "set_visibility", "symbol_prims", "place_on_sch", "doc_params", "sheet_params",- "part_parameters", "layers", "silk_state", "ripup_nets")},+ "part_parameters", "layers", "silk_state", "ripup_nets", "compile", "list_installed_libraries", "replicate_layout", "diff_pairs")}, # ---- 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), "add_mounting_hole": dict(phase=8, scenario=True),⋯ 1045 unchanged lines ⋯ svg0 = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>" r = E("import_svg", {"svg": svg0, "x": round(cx, 2), "y": round(cy, 2), "width": 2, "layer": "Dwgs.User", "validate": False}) record("import_svg_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a live SVG was not created through CreateItems")+ # 1.0.65: a footprint the board does not have is built from the library text and created live+ r = E("add_footprint", {"libraryId": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical", "x": round(cx, 2), "y": round(cy + 5, 2), "reference": "J", "value": "HDR2", "validate": False})+ record("add_footprint_built_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a footprint the board lacks was not built and created live") r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False) rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer") r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)⋯ 176 unchanged lines ⋯ own("ripup_nets", r, ok(r) and r.get("mutated") is not True, "the board did not open live, or the dry run changed something") ab(a.target, "kicad_close", {"force": True}, timeout=120) + # 9.4 the standard's native composites (1.0.65)+ print("[9.4] compile, installed libraries, replicate layout, diff pairs")+ r = S("compile", {"projectPath": pro}, 300); own("compile", r, ok(r) and "counts" in r and r.get("nets") is not None)+ r = S("list_installed_libraries", {"projectPath": pro}); own("list_installed_libraries", r, ok(r) and len(r.get("libraries") or []) > 10)+ st3 = ab(a.target, "kicad_placement_state", {"filePath": F}, timeout=150)+ caps = [p.get("ref") for p in (st3.get("footprints") or []) if str(p.get("ref", "")).startswith("C") and p.get("side") == "F.Cu"][:4]+ if len(caps) == 4:+ r = S("replicate_layout", {"filePath": F, "source": caps[:2], "targets": [caps[2:]], "dryRun": True, "allowOverlap": True, "allowOutside": True})+ own("replicate_layout", r, (ok(r) or r.get("errorCode") in ("courtyard_overlap", "outside_outline")) and len(r.get("moves") or []) == 1, "no plan for the target channel")+ else:+ own("replicate_layout", st3, False, "fewer than four front capacitors on the offline board")+ r = S("diff_pairs", {"filePath": B, "source": "file"}); own("diff_pairs", r, ok(r) and isinstance(r.get("pairs"), list))+ live_phase() k161_phase() std_phase()⋯ 64 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+@@ -1,104 +1,110 @@⋯ 40 unchanged lines ⋯ - **Adding items to an open board (from 1.0.64).** KiCad 10.0.x does not create items from text over its API (checked on 10.0.4 and 10.0.7), so the bridge encodes them as KiCad's protobuf items itself: - `board_outline` and `import_svg` create their shapes live (`commit:"create-items"`, one Undo step);- - `add_footprint` (also `duplicateOf` and `count`) and `add_mounting_hole` create a part live when the same- library footprint is already on that side of the board with the same pads and graphics: KiCad's own- encoding of it is copied, with fresh ids, the new pose, reference, value and pad nets;- - otherwise (a footprint the board does not have yet, and `update_footprints_from_library`) the verb refuses- with `live_create_unsupported`, says why in `liveCreateWhy` and changes nothing: save and close the board- (`kicad_save_board`, `kicad_close`) and call again, and the file is edited.+ - `add_footprint` (also `duplicateOf` and `count`) and `add_mounting_hole` create a part live: KiCad's own+ encoding is copied when the same library footprint is already on that side of the board, and from 1.0.65+ the part is otherwise built from the library's text (pads, graphics, texts, fields, 3D models);+ - a footprint with custom-shape pads, mask or paste margins or zones inside, and+ `update_footprints_from_library`, refuse with `live_create_unsupported`, say why in `liveCreateWhy` and+ change nothing: save and close the board (`kicad_save_board`, `kicad_close`) and call again, and the file+ is edited. Moving, editing, routing and deleting work live. KiCad's API stores every footprint it rebuilds (a move, a field or part edit) without a polygon's repeated closing point; replies name those parts in⋯ 15 unchanged lines ⋯ block under the standard's names), `sheet_params` (the BOM write-back: fields onto placed parts, every unit, on whichever sheet holds each), `part_parameters` (which fields the parts carry, or one field's values with the parts that lack it).+- **Checks and channels (1.0.65):** `compile` (ERC and the netlist, counted by class), `list_installed_libraries`+ (every symbol and footprint library KiCad loads, with missing ones flagged), `replicate_layout` (one channel's+ placement onto matching channels through `move_footprint`, one Undo step), `diff_pairs` (the pairs KiCad's+ router recognises by name, and the net class that sets their width and gap). - **The board:** `layers` (the whole layer set, with what the open editor shows), `silk_state` (every part's body box, designator and value as placed, and the free silkscreen), `ripup_nets` (every unlocked track, arc and via of the named nets, live, one Undo step; dry-run first).⋯ 28 unchanged lines ⋯ `prices` per quantity. Every verb here runs in the verb battery on a real Windows box before each release.+
Comments
Log in to comment.
No comments yet.