← Commit history
bridge.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