app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #309: kicad-lane: 1.0.66, teardrops and V-score panelize
12 files changed
+625−6
bridge.json+3−1rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_fab.rs+73rust/crates/kicad-bridge/src/verbs_windows.rs+118rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/panel.rs+373rust/crates/kicad-platform/src/lib.rs+2rust/crates/kicad-platform/src/win/uia.rs+32−1rust/crates/kicad-platform/src/windows.rs+3skills/kicad-bridge-test/run_verb_tests.py+9skills/kicad-schematic-and-board-edits/SKILL.md+7bridge.json+3−1@@ -274,7 +274,9 @@ "kicad_compile", "kicad_diff_pairs", "kicad_list_installed_libraries",- "kicad_replicate_layout"+ "kicad_replicate_layout",+ "kicad_teardrops",+ "kicad_panelize" ], "statusVerb": "kicad_status", "timeouts": {
rust/Cargo.lock+3−3@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.65"+version = "1.0.66" dependencies = [ "kicad-core", "kicad-platform",@@ -321,7 +321,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.65"+version = "1.0.66" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -348,7 +348,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.65"+version = "1.0.66" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.65"+version = "1.0.66" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_fab.rs+73@@ -90,6 +90,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_export_fab_package", "kicad_validate_for_fab"], pitfalls: &["prices change; the rates in assumptions are the bridge's, not the fab's live price list"], },+ Verb {+ name: "kicad_panelize",+ summary: "Step a rectangular board into a manufacturing panel on V-score: rows x cols copies abutting, each copy's nets renamed B<n>-<net>, one panel outline with rails above and below, V-cut lines on Cmts.User along every seam, three tooling holes and three fiducials on the rails; written as a new board and checked with KiCad's DRC.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+ input: "{\"pcbPath\": \"C:/.../x.kicad_pcb\" (or filePath), \"panelPath\"?: \"C:/.../x-panel.kicad_pcb\", \"rows\"?: 1, \"cols\"?: 2, \"separation\"?: \"vscore\", \"gapMm\"?: 0, \"railMm\"?: 5, \"toolingHoles\"?: true, \"fiducials\"?: true, \"validate\"?: true, \"overwrite\"?: false}",+ example: "kicad_panelize {\"pcbPath\":\"C:/Users/john/proj/board.kicad_pcb\",\"rows\":2,\"cols\":3}",+ hint: "The panel is a new .kicad_pcb (the board itself is untouched) with the project's rules copied beside it. drc is KiCad's DRC of the panel with its zones refilled; open it with kicad_open_board and export with kicad_export_manufacturing. Tell the fab the V-cut layer is Cmts.User.",+ related: &["kicad_export_manufacturing", "kicad_run_drc", "kicad_open_board", "kicad_validate_for_fab"],+ pitfalls: &["V-score only, and only for a rectangular board: a board with curves or notches is refused (it needs tabs and mouse bites; KiKit makes those).", "Zone fills are not copied: the panel's zones are refilled when KiCad opens it (kicad_refill_zones) and in the DRC here.", "Designators repeat across the copies, as on most panels; the BOM and placement files of the panel are per board, not per panel."],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -102,6 +112,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_export_fab_package" => fab_package(state, args), "kicad_validate_for_fab" => validate(state, args), "kicad_estimate_cost" => estimate(args),+ "kicad_panelize" => panelize(state, args), _ => return None, }) }@@ -417,6 +428,68 @@ fn estimate(args: &Value) -> Value { }) } ++/// `kicad_panelize`: a V-score panel of a rectangular board, as a new board file.+fn panelize(state: &mut State, args: &Value) -> Value {+ let Some(src) = arg_any(args, &["pcbPath", "filePath"]).map(std::path::PathBuf::from) else {+ return fail("missing_arg", "pcbPath is required", "The board to panelise (.kicad_pcb).");+ };+ if args.get("separation").and_then(Value::as_str).map(|s| s != "vscore").unwrap_or(false) || args.get("gapMm").and_then(Value::as_f64).map(|g| g > 0.0).unwrap_or(false) {+ return fail("not_supported", "Only V-score panels (boards abutting, gapMm 0) are made here", "Mouse bites need tabs cut into each outline: use KiKit for those, or separation:\"vscore\".");+ }+ let text = match std::fs::read_to_string(&src) {+ Ok(t) => t,+ Err(e) => return fail("board_not_found", format!("cannot read {}: {e}", norm(&src)), "Pass the board's absolute path."),+ };+ let stem = src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+ let out = arg_str(args, "panelPath").map(std::path::PathBuf::from).unwrap_or_else(|| src.with_file_name(format!("{stem}-panel.kicad_pcb")));+ if out.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {+ return fail("file_exists", format!("{} already exists", norm(&out)), "Pass overwrite:true to replace it, or another panelPath.");+ }+ let num = |k: &str, d: u64| args.get(k).and_then(Value::as_u64).unwrap_or(d) as usize;+ let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(true);+ let o = kicad_core::panel::Opts { rows: num("rows", 1), cols: num("cols", 2), rail_mm: args.get("railMm").and_then(Value::as_f64).unwrap_or(5.0), holes: flag("toolingHoles"), fiducials: flag("fiducials") };+ let (panel, mut report) = match kicad_core::panel::panelize(&text, &o) {+ Ok(x) => x,+ Err(e) => return fail(if e.contains("not a rectangle") || e.contains("curves") || e.contains("mouse bites") { "not_rectangular" } else { "panel_failed" }, e, "V-score suits rectangular boards; a shaped board needs tabs and mouse bites (KiKit)."),+ };+ if let Some(dir) = out.parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ if let Err(e) = kicad_core::libraries::write_atomic(&out, panel.as_bytes()) {+ return fail("write_failed", e, "Check the folder is writable.");+ }+ // the project's rules and libraries travel with the panel+ let mut copied = Vec::new();+ for ext in ["kicad_pro", "kicad_dru"] {+ let (from, to) = (src.with_extension(ext), out.with_extension(ext));+ if from.is_file() && !to.exists() && std::fs::copy(&from, &to).is_ok() {+ copied.push(norm(&to));+ }+ }+ report["success"] = json!(true);+ report["panel"] = json!(norm(&out));+ report["copied"] = json!(copied);+ report["output"] = json!(format!("{} boards ({} x {}) on a {} x {} mm V-score panel: {}", report["boards"], o.rows, o.cols, report["panelSizeMm"][0], report["panelSizeMm"][1], norm(&out)));+ if args.get("validate").and_then(Value::as_bool).unwrap_or(true) {+ // the panel against its copies: only what the panel adds beyond boards x the board's own findings is new+ let drc = crate::verbs::call(state, "kicad_run_drc", &json!({"filePath": norm(&out), "refillZones": true}));+ let base = crate::verbs::call(state, "kicad_run_drc", &json!({"filePath": norm(&src), "refillZones": true}));+ let n = |v: &Value, k: &str| v.pointer(&format!("/summary/{k}")).and_then(Value::as_i64).unwrap_or(0);+ let boards = (o.rows * o.cols) as i64;+ report["drc"] = if drc["success"] == true && base["success"] == true {+ let new_errors = n(&drc, "errors") - boards * n(&base, "errors");+ let new_unconnected = n(&drc, "unconnected") - boards * n(&base, "unconnected");+ json!({"panelErrors": n(&drc, "errors"), "boardErrors": n(&base, "errors"), "newErrors": new_errors, "newUnconnected": new_unconnected,+ "clean": new_errors <= 0 && new_unconnected <= 0, "first": drc.pointer("/summary/first").cloned().unwrap_or(Value::Null)})+ } else {+ json!({"ran": false, "why": if drc["success"] == true { base["error"].clone() } else { drc["error"].clone() }})+ };+ }+ report["_hint"] = json!("Open the panel with kicad_open_board (its zones refill there), check kicad_run_drc, then kicad_export_manufacturing. V-cut lines are on Cmts.User: name that layer to the fab.");+ report+}+ #[cfg(test)] mod tests { use super::*;
rust/crates/kicad-bridge/src/verbs_windows.rs+118@@ -234,6 +234,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"], pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"], },+ Verb {+ name: "kicad_teardrops",+ summary: "Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after.",+ mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"vias\"?: true, \"pthPads\"?: true, \"smdPads\"?: true, \"tJunctions\"?: false (track to track), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops)}",+ example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",+ hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). before and after count the teardrop areas; KiCad adds them as one Undo step. Save with kicad_save_board, then check with kicad_run_drc.",+ related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],+ pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],+ }, Verb { name: "kicad_click", summary: "Click at coordinates inside a KiCad window.",@@ -327,6 +337,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_state" => kicad_state(state, args), "kicad_screenshot_all" => screenshot_all(state), "kicad_send_key" => send_key(args),+ "kicad_teardrops" => teardrops(state, args), "kicad_click" => click(args), "kicad_fix_keyboard" => fix_keyboard(), "kicad_place_footprint" => place_footprint(state, args),@@ -3540,3 +3551,110 @@ fn same_board_path(a: &str, b: &str) -> bool { fn same_board_path(a: &str, b: &str) -> bool { a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/")) }++// ---------------------------------------------------------------------------+// kicad_teardrops: KiCad's own Edit > Edit Teardrops+// ---------------------------------------------------------------------------++/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or+/// None without the IPC API. Read straight from KiCad, not through another verb: this runs+/// under GUI_LOCK, which verbs::call would try to take again.+/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.+#[cfg(feature = "ipc")]+fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {+ use kicad_core::{detect, ipc};+ let info = state.kicad_info();+ let ctx = ipc::Ctx {+ kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+ config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+ };+ let (text, _, _) = ipc::board_text(&ctx, args).ok()?;+ Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))+}++#[cfg(not(feature = "ipc"))]+fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {+ None+}++fn teardrops(state: &mut State, args: &Value) -> Value {+ let Some(path) = arg_str(args, "filePath") else {+ return fail("missing_arg", "filePath is required", "The .kicad_pcb open in the PCB editor.");+ };+ let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let Some(editor) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) else {+ return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor.");+ };+ let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+ let remove = flag("remove", false);+ let before = teardrop_count(state, args).map(|(n, _)| n);+ match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {+ Ok(Some(_)) => {}+ Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),+ Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),+ }+ let mut dialog = None;+ for _ in 0..60 {+ if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {+ dialog = Some(d.hwnd);+ break;+ }+ sleep_ms(250);+ }+ let Some(dlg) = dialog else {+ return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");+ };+ let mut steps = Vec::new();+ let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };+ let options = [("PTH pads", flag("pthPads", true)), ("SMD pads", flag("smdPads", true)), ("Vias", flag("vias", true)), ("Track to track", flag("tJunctions", false)), ("Selected items only", flag("selectedOnly", false))];+ let mut failed = None;+ for (label, on) in options {+ if let Err(e) = set(label, on) {+ failed = Some(e);+ break;+ }+ }+ if failed.is_none() {+ let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };+ match native().uia_invoke(dlg, action) {+ Ok(r) => steps.push(r),+ Err(e) => failed = Some(e),+ }+ }+ if let Some(e) = failed {+ let _ = native().click_button(dlg, &["close"]);+ return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");+ }+ if let Err(e) = native().click_button(dlg, &["apply and close"]) {+ let _ = native().click_button(dlg, &["close"]);+ return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");+ }+ steps.push("apply and close".into());+ let started = Instant::now();+ while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {+ sleep_ms(250);+ }+ if window_alive(dlg) {+ return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");+ }+ let after_read = teardrop_count(state, args);+ let after = after_read.as_ref().map(|(n, _)| *n);+ let revision = after_read.map(|(_, r)| r);+ let delta = match (before, after) {+ (Some(b), Some(a)) => Some(a as i64 - b as i64),+ _ => None,+ };+ json!({+ "success": true,+ "output": match delta {+ Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },+ None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),+ },+ "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),+ "mutated": delta.map(|d| d != 0),+ "dialogSteps": steps,+ "revision": revision,+ "saved": false,+ "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. Save with kicad_save_board, then kicad_run_drc: teardrops are copper areas and take part in DRC.",+ })+}
rust/crates/kicad-core/src/lib.rs+1@@ -51,6 +51,7 @@ pub mod fpedit; pub mod symedit; pub mod libops; pub mod board_reads;+pub mod panel; pub mod libsearch; pub mod sxedit; pub mod fp_text;
rust/crates/kicad-core/src/panel.rsadded+373@@ -0,0 +1,373 @@+//! A manufacturing panel from one board: the board stepped into rows x columns on a V-score+//! grid (the copies abut), each copy's nets renamed `B<n>-<net>` so the copies stay separate nets,+//! every copy's own outline replaced by one panel outline with rails above and below, V-cut lines on+//! Cmts.User along every seam, three tooling holes and three fiducials on the rails.+//!+//! Only a rectangular board (its Edge.Cuts a rectangle) is panelised on V-score here: a board with+//! curves or notches needs tabs and mouse bites (KiKit does that).++use std::collections::HashMap;++use serde_json::{json, Value};++use crate::schematic::{new_uuid, Document, Node};++pub struct Opts {+ pub rows: usize,+ pub cols: usize,+ pub rail_mm: f64,+ pub holes: bool,+ pub fiducials: bool,+}++const HEADER: &[&str] = &["version", "generator", "generator_version", "general", "paper", "title_block", "layers", "setup", "property", "net", "embedded_fonts", "embedded_files"];++fn fmt(v: f64) -> String {+ let r = (v * 1e6).round() / 1e6 + 0.0;+ let s = format!("{r}");+ s+}++/// The board's outline as a rectangle [x0, y0, x1, y1], or why it is not one.+pub fn rect_outline(root: &Node) -> Result<[f64; 4], String> {+ let edges: Vec<&Node> = root.children().iter().filter(|c| c.head().map(|h| h.starts_with("gr_")).unwrap_or(false) && c.child_value("layer").as_deref() == Some("Edge.Cuts")).collect();+ if edges.is_empty() {+ return Err("the board has no Edge.Cuts outline".into());+ }+ let mut segs: Vec<((f64, f64), (f64, f64))> = Vec::new();+ for e in &edges {+ let pt = |t: &str| e.child(t).and_then(|c| Some((c.num(0)?, c.num(1)?)));+ match e.head() {+ Some("gr_line") => segs.push((pt("start").ok_or("line without start")?, pt("end").ok_or("line without end")?)),+ Some("gr_rect") => {+ let (a, b) = (pt("start").ok_or("rect without start")?, pt("end").ok_or("rect without end")?);+ segs.extend([(a, (b.0, a.1)), ((b.0, a.1), b), (b, (a.0, b.1)), ((a.0, b.1), a)]);+ }+ Some("gr_poly") => {+ let pts: Vec<(f64, f64)> = e.child("pts").map(|p| p.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))).collect()).unwrap_or_default();+ for i in 0..pts.len() {+ segs.push((pts[i], pts[(i + 1) % pts.len()]));+ }+ }+ Some(other) => return Err(format!("the outline has a {} (curves need tabs and mouse bites, not V-score)", other.trim_start_matches("gr_"))),+ None => {}+ }+ }+ let xs = segs.iter().flat_map(|(a, b)| [a.0, b.0]);+ let ys = segs.iter().flat_map(|(a, b)| [a.1, b.1]);+ let (x0, x1) = (xs.clone().fold(f64::MAX, f64::min), xs.fold(f64::MIN, f64::max));+ let (y0, y1) = (ys.clone().fold(f64::MAX, f64::min), ys.fold(f64::MIN, f64::max));+ let near = |a: f64, b: f64| (a - b).abs() < 1e-4;+ for (a, b) in &segs {+ let on_h = near(a.1, b.1) && (near(a.1, y0) || near(a.1, y1));+ let on_v = near(a.0, b.0) && (near(a.0, x0) || near(a.0, x1));+ if !(on_h || on_v) {+ return Err(format!("the outline is not a rectangle (an edge from ({}, {}) to ({}, {}) lies inside it); a shaped board needs tabs and mouse bites", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1)));+ }+ }+ let perimeter: f64 = segs.iter().map(|(a, b)| (b.0 - a.0).abs() + (b.1 - a.1).abs()).sum();+ if !near(perimeter, 2.0 * ((x1 - x0) + (y1 - y0))) {+ return Err("the outline's edges do not close one rectangle".into());+ }+ Ok([x0, y0, x1, y1])+}++/// Every coordinate node inside an item (not a footprint), moved by (dx, dy).+fn shift(n: &mut Node, dx: f64, dy: f64) {+ if matches!(n.head(), Some("start" | "end" | "mid" | "center" | "at" | "xy")) {+ if let (Some(x), Some(y)) = (n.num(0), n.num(1)) {+ n.set_arg(0, Node::atom(fmt(x + dx)));+ n.set_arg(1, Node::atom(fmt(y + dy)));+ }+ return;+ }+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ shift(k, dx, dy);+ }+ }+ }+}++/// Every `(uuid ..)`/`(tstamp ..)` in a copy made fresh (ids must be unique across the panel).+fn fresh_ids(n: &mut Node) {+ if matches!(n.head(), Some("uuid" | "tstamp")) {+ n.set_arg(0, Node::quoted(&new_uuid()));+ return;+ }+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ fresh_ids(k);+ }+ }+ }+}++/// Nets of one copy renamed: `(net "X")` (and a zone's `(net_name "X")`) to the copy's name;+/// numbered nets `(net 3)` / `(net 3 "X")` to the copy's number and name.+fn rename_nets(n: &mut Node, copy: usize, numbers: &HashMap<i64, i64>, names: &HashMap<String, String>) {+ match n.head() {+ Some("net") => {+ let a0 = n.arg(0);+ match a0.as_deref().and_then(|s| s.parse::<i64>().ok()) {+ Some(code) => {+ if let Some(new) = numbers.get(&code) {+ n.set_arg(0, Node::atom(new.to_string()));+ }+ if let Some(name) = n.arg(1).and_then(|x| names.get(&x).cloned()) {+ n.set_arg(1, Node::quoted(&name));+ }+ }+ None => {+ if let Some(name) = a0.and_then(|x| names.get(&x).cloned()) {+ n.set_arg(0, Node::quoted(&name));+ }+ }+ }+ return;+ }+ Some("net_name") => {+ if let Some(name) = n.arg(0).and_then(|x| names.get(&x).cloned()) {+ n.set_arg(0, Node::quoted(&name));+ }+ return;+ }+ _ => {}+ }+ let _ = copy;+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ rename_nets(k, copy, numbers, names);+ }+ }+ }+}++fn parse(text: &str) -> Result<Node, String> {+ crate::schematic::parse_node(text.trim())+}++fn tool_footprint(reference: &str, x: f64, y: f64, fiducial: bool, hole_mm: f64) -> Result<Node, String> {+ let pad = if fiducial {+ format!("(pad \"1\" smd circle (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\") (solder_mask_margin 0.5) (uuid \"{}\"))", new_uuid())+ } else {+ format!("(pad \"\" np_thru_hole circle (at 0 0) (size {h} {h}) (drill {h}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))", new_uuid(), h = fmt(hole_mm))+ };+ let name = if fiducial { "Fiducial_1mm_Mask2mm" } else { "ToolingHole" };+ parse(&format!(+ "(footprint \"Panel:{name}\" (layer \"F.Cu\") (uuid \"{}\") (at {} {}) \+ (property \"Reference\" \"{reference}\" (at 0 -2 0) (layer \"F.SilkS\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \+ (property \"Value\" \"{name}\" (at 0 2 0) (layer \"F.Fab\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \+ (attr {}exclude_from_pos_files exclude_from_bom) {pad})",+ new_uuid(), fmt(x), fmt(y), new_uuid(), new_uuid(), if fiducial { "smd " } else { "" }+ ))+}++fn line(layer: &str, a: (f64, f64), b: (f64, f64), width: f64) -> Result<Node, String> {+ parse(&format!("(gr_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer \"{layer}\") (uuid \"{}\"))", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1), fmt(width), new_uuid()))+}++/// The panel's board text and a report.+pub fn panelize(text: &str, o: &Opts) -> Result<(String, Value), String> {+ if o.rows == 0 || o.cols == 0 || o.rows * o.cols > 200 {+ return Err("rows and cols must be at least 1, at most 200 boards".into());+ }+ let mut doc = Document::parse(text)?;+ if doc.root.head() != Some("kicad_pcb") {+ return Err("not a board".into());+ }+ let [x0, y0, x1, y1] = rect_outline(&doc.root)?;+ let (w, h) = (x1 - x0, y1 - y0);+ let kids: Vec<Node> = doc.root.children().to_vec();+ // numbered nets: the declaration table+ let decls: Vec<(i64, String)> = kids.iter().filter(|c| c.head() == Some("net")).filter_map(|c| Some((c.arg(0)?.parse().ok()?, c.arg(1).unwrap_or_default()))).collect();+ let span = decls.iter().map(|(c, _)| *c).max().unwrap_or(0) + 1;+ // every net name in use (numbered or by name), for the name-style boards+ let mut all_names: Vec<String> = decls.iter().map(|(_, n)| n.clone()).collect();+ fn collect_names(n: &Node, out: &mut Vec<String>) {+ if matches!(n.head(), Some("net" | "net_name")) {+ let name = if n.head() == Some("net_name") { n.arg(0) } else if n.arg(0).and_then(|s| s.parse::<i64>().ok()).is_some() { n.arg(1) } else { n.arg(0) };+ if let Some(x) = name {+ if !out.contains(&x) {+ out.push(x);+ }+ }+ return;+ }+ for k in n.children().iter().filter(|k| k.is_list()) {+ collect_names(k, out);+ }+ }+ for k in kids.iter().filter(|c| !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)) {+ collect_names(k, &mut all_names);+ }+ let items: Vec<&Node> = kids.iter().filter(|c| c.is_list() && !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)).collect();+ let mut out_items: Vec<Node> = Vec::new();+ let mut new_decls: Vec<Node> = Vec::new();+ let mut n = 0usize;+ for r in 0..o.rows {+ for c in 0..o.cols {+ n += 1;+ let (dx, dy) = (c as f64 * w, r as f64 * h);+ let names: HashMap<String, String> = all_names.iter().filter(|x| !x.is_empty()).map(|x| (x.clone(), format!("B{n}-{x}"))).collect();+ let numbers: HashMap<i64, i64> = decls.iter().map(|(code, _)| (*code, if *code == 0 { 0 } else { code + span * (n as i64 - 1) })).collect();+ for (code, name) in &decls {+ if *code == 0 {+ continue;+ }+ new_decls.push(parse(&format!("(net {} \"{}\")", numbers[code], names.get(name).cloned().unwrap_or_else(|| name.clone()).replace('"', "\\\"")))?);+ }+ for it in &items {+ let head = it.head().unwrap_or("");+ if head == "group" || (head.starts_with("gr_") && it.child_value("layer").as_deref() == Some("Edge.Cuts")) {+ continue;+ }+ let mut cp = (*it).clone();+ if head == "footprint" {+ if let Some(at) = cp.child_mut("at") {+ shift(at, dx, dy);+ }+ } else {+ if head == "zone" {+ // fills are recomputed by KiCad (kicad_refill_zones or kicad-cli --refill-zones)+ if let Some(k) = cp.children_mut() {+ k.retain(|c| !matches!(c.head(), Some("filled_polygon" | "fill_segments")));+ }+ }+ shift(&mut cp, dx, dy);+ }+ fresh_ids(&mut cp);+ rename_nets(&mut cp, n, &numbers, &names);+ out_items.push(cp);+ }+ }+ }+ // the panel: outline with rails, V-cuts, tooling holes, fiducials+ let (pw, ph) = (o.cols as f64 * w, o.rows as f64 * h);+ let rail = o.rail_mm.max(0.0);+ let (top, bottom) = (y0 - rail, y0 + ph + rail);+ let mut panel_items: Vec<Node> = Vec::new();+ for (a, b) in [((x0, top), (x0 + pw, top)), ((x0 + pw, top), (x0 + pw, bottom)), ((x0 + pw, bottom), (x0, bottom)), ((x0, bottom), (x0, top))] {+ panel_items.push(line("Edge.Cuts", a, b, 0.1)?);+ }+ let mut vcuts = 0;+ for r in 0..=o.rows {+ let y = y0 + r as f64 * h;+ if rail <= 0.0 && (r == 0 || r == o.rows) {+ continue;+ }+ panel_items.push(line("Cmts.User", (x0 - 2.0, y), (x0 + pw + 2.0, y), 0.1)?);+ vcuts += 1;+ }+ for c in 1..o.cols {+ let x = x0 + c as f64 * w;+ panel_items.push(line("Cmts.User", (x, top - 2.0), (x, bottom + 2.0), 0.1)?);+ vcuts += 1;+ }+ panel_items.push(parse(&format!("(gr_text \"V-CUT\" (at {} {} 0) (layer \"Cmts.User\") (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)) (justify left)))", fmt(x0 + pw + 3.0), fmt(y0), new_uuid()))?);+ let (mut holes, mut fids) = (0, 0);+ if rail >= 3.0 {+ let hole = if rail >= 6.0 { 3.0 } else { 2.0 };+ let inset = (rail / 2.0).max(2.5);+ let mid_top = top + rail / 2.0;+ let mid_bot = bottom - rail / 2.0;+ if o.holes {+ for (i, (x, y)) in [(x0 + inset, mid_top), (x0 + pw - inset, mid_top), (x0 + inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("TH{}", i + 1), x, y, false, hole)?);+ holes += 1;+ }+ }+ if o.fiducials && pw > 4.0 * inset + 6.0 {+ for (i, (x, y)) in [(x0 + 3.0 * inset, mid_top), (x0 + pw - 3.0 * inset, mid_top), (x0 + 3.0 * inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("FID{}", i + 1), x, y, true, 0.0)?);+ fids += 1;+ }+ }+ }+ // rebuild: the header, the renamed net table, the copies, the panel+ let mut root_kids: Vec<Node> = Vec::new();+ let lead = kids.iter().find(|c| c.is_list()).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t".into());+ let mut decl_done = false;+ for k in &kids {+ let head = k.head();+ if head == Some("net") {+ if !decl_done {+ root_kids.push(k.clone()); // net 0+ for mut d in new_decls.drain(..) {+ d.lead = lead.clone();+ root_kids.push(d);+ }+ decl_done = true;+ }+ continue;+ }+ if !k.is_list() || head.map(|h| HEADER.contains(&h)).unwrap_or(true) {+ root_kids.push(k.clone());+ }+ }+ for mut it in out_items.into_iter().chain(panel_items) {+ it.lead = lead.clone();+ root_kids.push(it);+ }+ if let Some(r) = doc.root.children_mut() {+ *r = root_kids;+ }+ let text_out = doc.to_text();+ Ok((text_out, json!({+ "boards": o.rows * o.cols, "rows": o.rows, "cols": o.cols,+ "boardSizeMm": [w, h], "panelSizeMm": [pw, ph + 2.0 * rail], "railMm": rail,+ "separation": "vscore", "vCuts": vcuts, "vCutLayer": "Cmts.User", "toolingHoles": holes, "fiducials": fids,+ "netsRenamed": all_names.iter().filter(|x| !x.is_empty()).count() * o.rows * o.cols,+ })))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture(name: &str) -> String {+ std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(name)).unwrap()+ }++ #[test]+ fn a_rectangular_board_panelises_with_separate_nets() {+ let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");+ let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 3, rail_mm: 5.0, holes: true, fiducials: true }).unwrap();+ assert_eq!(rep["boards"], 6);+ let doc = Document::parse(&out).unwrap();+ let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ let src = Document::parse(&t).unwrap().root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ assert_eq!(fps, src * 6 + 6, "six copies plus three holes and three fiducials");+ assert!(out.contains("\"B6-GND\"") && out.contains("\"B1-GND\""), "nets renamed per copy");+ assert!(!out.contains("(net \"GND\")"), "no copy keeps the shared name");+ // ids unique across the panel+ let ids: Vec<&str> = out.match_indices("(uuid \"").map(|(i, _)| &out[i + 7..i + 43]).collect();+ let set: std::collections::HashSet<&&str> = ids.iter().collect();+ assert_eq!(set.len(), ids.len(), "every uuid unique");+ // one outline: four Edge.Cuts lines+ let edges = doc.root.children().iter().filter(|c| c.child_value("layer").as_deref() == Some("Edge.Cuts")).count();+ assert_eq!(edges, 4);+ assert!(crate::schematic::Document::parse(&out).is_ok());+ }++ #[test]+ fn a_shaped_board_is_refused() {+ let t = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_arc (start 10 0) (mid 12 5) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\")) (gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\")))";+ let e = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).unwrap_err();+ assert!(e.contains("arc"), "{e}");+ let notched = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 5 10) (layer \"Edge.Cuts\")) (gr_line (start 5 10) (end 5 8) (layer \"Edge.Cuts\")) (gr_line (start 5 8) (end 0 8) (layer \"Edge.Cuts\")) (gr_line (start 0 8) (end 0 0) (layer \"Edge.Cuts\")))";+ assert!(panelize(notched, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).is_err());+ }++ #[test]+ fn numbered_nets_get_a_table_entry_per_copy() {+ let t = "(kicad_pcb (version 20250907)\n\t(net 0 \"\")\n\t(net 1 \"GND\")\n\t(net 2 \"VCC\")\n\t(footprint \"L:R\" (layer \"F.Cu\") (uuid \"f1\") (at 5 5) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\") (uuid \"p1\")))\n\t(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net 2) (uuid \"s1\"))\n\t(gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (uuid \"e1\"))\n)\n";+ let (out, _) = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 0.0, holes: false, fiducials: false }).unwrap();+ assert!(out.contains("(net 1 \"B1-GND\")") && out.contains("(net 4 \"B2-GND\")") && out.contains("(net 5 \"B2-VCC\")"), "{out}");+ assert!(out.contains("(net 4 \"B2-GND\"))") || out.contains("(net 4 \"B2-GND\") (uuid"), "the second copy's pad uses the new number: {out}");+ assert!(out.contains("(start 11 1)"), "the second copy moved 10 mm right: {out}");+ }+}
rust/crates/kicad-platform/src/lib.rs+2@@ -172,6 +172,8 @@ pub trait Platform: Sync + Send { fn uia_invoke(&self, _hwnd: u64, _name: &str) -> Result<String, String> { nope("uia_invoke") } /// UI Automation: ValuePattern.SetValue on the descendant whose name contains `name`. fn uia_set_value(&self, _hwnd: u64, _name: &str, _text: &str) -> Result<(), String> { nope("uia_set_value") }+ /// Set a dialog's check box (exact caption) on or off; "unchanged:" when it already was.+ fn uia_set_checked(&self, _hwnd: u64, _name: &str, _on: bool) -> Result<String, String> { nope("uia_set_checked") } /// UI Automation: names of descendants matching a substring (bounded). fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") } fn foreground(&self) -> Result<u64, String> { nope("foreground") }
rust/crates/kicad-platform/src/win/uia.rs+32−1@@ -17,7 +17,7 @@ //! probe first and refuses a hung window. use uiautomation::core::UICacheRequest;-use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UIValuePattern};+use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UITogglePattern, UIValuePattern}; use uiautomation::types::{ControlType, Handle, TreeScope, UIProperty}; use uiautomation::variants::Variant; use uiautomation::{UIAutomation, UIElement};@@ -204,6 +204,37 @@ pub fn click_button(dialog: HWND, labels: &[&str]) -> Result<String, String> { Err(format!("control_not_found: no UIA button {labels:?} on {} (buttons seen: {seen:?})", hwnd_u64(dialog))) } +/// Set the check box named exactly `name` (mnemonic stripped, case-insensitive) to `on` through+/// its TogglePattern. Answers "set:<name>=on|off" when it toggled, "unchanged:<name>" otherwise.+pub fn set_checked(h: HWND, name: &str, on: bool) -> Result<String, String> {+ require_responsive(h, "uia_set_checked", 500)?;+ let _com = Com::init()?;+ let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+ let root = root_of(&ua, h)?;+ let req = cache(&ua)?;+ let cond = ua+ .create_property_condition(UIProperty::ControlType, Variant::from(ControlType::CheckBox as i32), None)+ .map_err(|e| format!("UIA condition: {e}"))?;+ let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll(CheckBox): {e}"))?;+ let want = name.trim().to_lowercase();+ let mut seen = Vec::new();+ for el in all.iter().take(MAX_ELEMENTS) {+ let n = el.get_cached_name().unwrap_or_default();+ if strip_amp(&n).trim().to_lowercase() != want {+ seen.push(n);+ continue;+ }+ let p = el.get_pattern::<UITogglePattern>().map_err(|_| format!("not_toggleable: {n:?} has no TogglePattern"))?;+ let is_on = matches!(p.get_toggle_state().map_err(|e| format!("ToggleState of {n:?}: {e}"))?, uiautomation::types::ToggleState::On);+ if is_on == on {+ return Ok(format!("unchanged:{n}"));+ }+ p.toggle().map_err(|e| format!("Toggle on {n:?}: {e}"))?;+ return Ok(format!("set:{n}={}", if on { "on" } else { "off" }));+ }+ Err(format!("control_not_found: no check box {name:?} on {} (check boxes seen: {seen:?})", hwnd_u64(h)))+}+ /// Names of descendants containing `name` (all named descendants when empty), bounded. pub fn find_names(h: HWND, name: &str, limit: usize) -> Result<Vec<String>, String> { require_responsive(h, "uia_find", 500)?;
rust/crates/kicad-platform/src/windows.rs+3@@ -199,6 +199,9 @@ impl Platform for Native { fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> { win::uia::invoke(live(h)?, name) }+ fn uia_set_checked(&self, h: u64, name: &str, on: bool) -> Result<String, String> {+ win::uia::set_checked(live(h)?, name, on)+ } fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> { win::uia::set_value(live(h)?, name, text) }
skills/kicad-bridge-test/run_verb_tests.py+9@@ -190,6 +190,8 @@ def matrix(w: dict) -> dict: **{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", "compile", "list_installed_libraries", "replicate_layout", "diff_pairs")},+ "teardrops": dict(phase=8, scenario=True),+ "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), "add_mounting_hole": dict(phase=8, scenario=True),@@ -1191,6 +1193,9 @@ def main(): r = K("validate_for_fab", {"filePath": B, "fab": "jlcpcb"}, 300); rec("validate_for_fab", r, ok(r)) r = K("estimate_cost", {"filePath": B, "quantities": [5, 50]}); rec("estimate_cost", r, ok(r)) r = K("set_layer_count", {"filePath": w["offline"], "count": 4}); rec("set_layer_count", r, ok(r))+ # 1.0.66: a V-score panel (a shaped board is refused cleanly: that needs mouse bites)+ r = K("panelize", {"pcbPath": B, "panelPath": OUT + "/panel/panel.kicad_pcb", "rows": 2, "cols": 2, "overwrite": True}, 300)+ rec("panelize", r, (ok(r) and (r.get("drc") or {}).get("newErrors", 1) <= 0) or r.get("errorCode") == "not_rectangular", "the panel failed or adds DRC errors beyond its copies") # 8.4 the live board (a second scratch copy, never saved over a fixture) print("[8.4] live board edits")@@ -1244,6 +1249,10 @@ def main(): # 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")+ # 1.0.66: teardrops through KiCad's own Edit Teardrops dialog, added then removed again+ r = E("teardrops", {"filePath": L2}, 240)+ r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}+ record("teardrops", {"add": r, "remove": r2}, ok(r) and ok(r2) and (r.get("after") or 0) >= (r.get("before") or 0) and r2.get("after") == 0, "teardrops were not added and removed again") 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)
skills/kicad-schematic-and-board-edits/SKILL.md+7@@ -83,6 +83,13 @@ From 1.0.64, the EDA Bridge Standard's library housekeeping: `export_ipc2581`, `export_ipc356`, `export_gencad`, `export_dxf`, `validate_for_fab` (the standard's `fab_check`), `estimate_cost`. +From 1.0.66:+- `teardrops`: adds or removes teardrops with KiCad's own Edit Teardrops dialog on the board open in the PCB editor,+ counting them before and after and returning the new revision.+- `panelize`: steps a rectangular board into a V-score panel as a new board, with nets renamed per copy, rails,+ V-cuts on Cmts.User, tooling holes and fiducials. Its KiCad DRC is compared with the board's own, so `newErrors`+ counts only what the panel adds. A shaped board needs mouse bites (KiKit).+ ## The EDA Bridge Standard's shapes (from 1.0.63) These verbs are in the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard) (0.3.0), so an AI