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
1.0.19: NATIVE (Rust) build. 1.0.19: custom pads written as the hull of their real copper so Freerouting cannot cross a MOSFET drain tab (wiki #93); kicad_autoroute reservePlanes switch (default true).
4 files changed
+180−13
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.18"+version = "1.0.19" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.18"+version = "1.0.19" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.18"+version = "1.0.19" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.18"+version = "1.0.19" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+8−5@@ -150,11 +150,12 @@ pub static VERBS: &[Verb] = &[ name: "kicad_autoroute", summary: "Route a board with the engine the USER picked: \"ai\" (recommended) returns the plan request you drive with kicad_route_net; \"freerouting\" runs Freerouting headless (installed on demand) and lands its copper live as one undo step per net, or into the closed file.", mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 900,- input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool, \"passes\"?: 20, \"threads\"?: n, \"timeoutSec\"?: 600, \"apply\"?: \"file\" | \"ipc\", \"backup\"?: true, \"job\"?: true | \"<id>\"}",+ input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool, \"passes\"?: 20, \"threads\"?: n, \"timeoutSec\"?: 600, \"apply\"?: \"file\" | \"ipc\", \"backup\"?: true, \"job\"?: true | \"<id>\", \"reservePlanes\"?: true}", example: "kicad_autoroute {\"engine\":\"ai\",\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}", hint: "ASK THE USER WHICH ENGINE THEY WANT BEFORE ROUTING and name both every time. ai (recommended): any capable model can drive kicad_route_net (GPT-6 Astra in Codex has routed a 94-footprint board to zero unconnected items this way, Claude Fable 5.1 can, newer models will); the routing verbs give it pads, nets, zones, a live revision, one native undo step per trace and DRC per step, so it honours the schematic's intent and can explain every trace. freerouting: deterministic, installed on demand (88 MB download, bundled Java, no elevation), kicad_freerouting {\"action\":\"status\"} says whether it is present; not installed answers freerouting_not_installed with the install call, and you ask the user before installing. Report which engine produced the copper. Over the relay use job:true and poll; the relay times out around 130 s while the bridge keeps working, and the result of a timed-out synchronous call is not retrievable: kicad_autoroute {\"engine\":\"freerouting\",\"job\":true} answers at once with a job id, kicad_autoroute {\"job\":\"<id>\"} reports running (stepLabel, percent), done (the full routing reply) or failed, kept for an hour. A direct caller may still wait synchronously (timeout 900 s).", related: &["kicad_freerouting", "kicad_routing_state", "kicad_route_net", "kicad_routing_validate", "kicad_board_pads"],- pitfalls: &["Never pick the engine for the user; the hint makes you ask (same pattern as foregroundReason).", "engine ai mutates nothing by itself: the copper lands through kicad_route_net calls you make afterwards.", "engine freerouting never installs anything by itself: freerouting_not_installed means ask, then kicad_freerouting {\"action\":\"install\"}, then call again.", "engine freerouting applies live (apply ipc: the board open in the PCB editor with the IPC API on, one native undo step per net named Freerouting <net>) or to the closed file (apply file, .adom-bak beside it); auto picks ipc when the board is open, file otherwise.", "Oval, roundrect and custom pads reach the router as their bounding rect (conservative); dsn.approximatedPads lists them.", "Over the relay use job:true and poll; the relay times out around 130 s while the bridge keeps working, and the result of a timed-out synchronous call is not retrievable. A real board takes minutes (a 59-net four-layer board: 40 passes), so job:true is the default choice over ab.", "One autoroute job per board at a time: job_busy names the running id, poll it to completion (or failure) before starting another. The poll takes no engine; a finished job answers for an hour, then job_not_found."],+ pitfalls: &["Never pick the engine for the user; the hint makes you ask (same pattern as foregroundReason).",+ "freerouting reserves inner plane layers by default (dsn.stats.powerLayers, vias only), which keeps the planes whole but leaves a dense board with more unconnected items; reservePlanes:false lets signals cross the planes. Say which one you ran.", "engine ai mutates nothing by itself: the copper lands through kicad_route_net calls you make afterwards.", "engine freerouting never installs anything by itself: freerouting_not_installed means ask, then kicad_freerouting {\"action\":\"install\"}, then call again.", "engine freerouting applies live (apply ipc: the board open in the PCB editor with the IPC API on, one native undo step per net named Freerouting <net>) or to the closed file (apply file, .adom-bak beside it); auto picks ipc when the board is open, file otherwise.", "Oval, roundrect and custom pads reach the router as their bounding rect (conservative); dsn.approximatedPads lists them.", "Over the relay use job:true and poll; the relay times out around 130 s while the bridge keeps working, and the result of a timed-out synchronous call is not retrievable. A real board takes minutes (a 59-net four-layer board: 40 passes), so job:true is the default choice over ab.", "One autoroute job per board at a time: job_busy names the running id, poll it to completion (or failure) before starting another. The poll takes no engine; a finished job answers for an hour, then job_not_found."], }, Verb { name: "kicad_freerouting",@@ -817,6 +818,7 @@ struct Prepared { live_args: Value, backup: bool, dry_run: bool,+ reserve_planes: bool, } fn freerouting_prepare(args: &Value, dry_run: bool) -> Result<Prepared, Value> {@@ -861,7 +863,8 @@ fn freerouting_prepare(args: &Value, dry_run: bool) -> Result<Prepared, Value> { live_args[k] = v.clone(); } }- Ok(Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup: backup_wanted(args), dry_run })+ let reserve_planes = args.get("reservePlanes").and_then(Value::as_bool).unwrap_or(true);+ Ok(Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup: backup_wanted(args), dry_run, reserve_planes }) } /// Engine `freerouting`, synchronous: board -> DSN -> Freerouting -> SES -> copper applied@@ -899,7 +902,7 @@ fn gui_guard(own: bool) -> Option<std::sync::MutexGuard<'static, ()>> { /// holds nothing while Freerouting runs for minutes, so window verbs, status polls and the /// job poll are never queued behind a routing pass. fn freerouting_execute(state: &mut State, prep: Prepared, phase: &Phase, own_gui_lock: bool) -> Value {- let Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup, dry_run } = prep;+ let Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup, dry_run, reserve_planes } = prep; let (text, board, mut revision): (String, pcb::Board, Option<String>) = if use_ipc { let gui = gui_guard(own_gui_lock); let snap = live_snapshot(state, &live_args);@@ -914,7 +917,7 @@ fn freerouting_execute(state: &mut State, prep: Prepared, phase: &Phase, own_gui let stem = loaded.path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into()); let rules = dsn::rules_for(&loaded.path, &board); phase.step("writing DSN", Some(3));- let out = match dsn::write_dsn(&board, &text, &stem, &rules, wanted.as_deref()) {+ let out = match dsn::write_dsn_with(&board, &text, &stem, &rules, wanted.as_deref(), reserve_planes) { Ok(d) => d, Err(e) => return fail("dsn_failed", format!("could not write the Specctra DSN: {e}"), "The board needs at least one copper layer and, ideally, a closed Edge.Cuts outline. kicad_board_pads shows what the parser sees."), };
rust/crates/kicad-core/src/dsn.rs+168−4@@ -463,6 +463,97 @@ struct PadDef { offset: (f64, f64), layers: Vec<String>, net: String,+ /// A custom pad's drawn copper, as points in the pad's own frame (before its rotation).+ /// Empty for every other shape. `custom_exact` is false when a primitive kind was not+ /// understood, so the pad is reported as approximated.+ primitives: Vec<(f64, f64)>,+ custom_exact: bool,+}++/// Points of a custom pad's `(primitives ...)`: filled polygons and rects exactly, circles+/// as 16-gons, stroked lines and arcs by their end points widened by half the stroke. The+/// caller takes the convex hull, which is a superset of the real copper (wiki #93: writing+/// only the 1x1 mm anchor let Freerouting route across the MOSFETs' 4.75 mm drain tabs).+fn custom_primitives(pad: &Sx) -> (Vec<(f64, f64)>, bool) {+ let mut pts = Vec::new();+ let mut exact = true;+ let Some(prims) = pad.find("primitives") else { return (pts, true) };+ for p in prims.as_list().into_iter().flatten().skip(1) {+ let width = p.find("width").and_then(|w| atom_f64(w, 1)).unwrap_or(0.0);+ let grow = |pts: &mut Vec<(f64, f64)>, x: f64, y: f64| {+ let r = width / 2.0;+ if r > 0.0 {+ pts.extend([(x - r, y - r), (x + r, y - r), (x + r, y + r), (x - r, y + r)]);+ } else {+ pts.push((x, y));+ }+ };+ match p.tag() {+ Some("gr_poly") => {+ for q in p.find("pts").into_iter().flat_map(|l| l.find_all("xy")) {+ if let (Some(x), Some(y)) = (atom_f64(q, 1), atom_f64(q, 2)) {+ grow(&mut pts, x, y);+ }+ }+ }+ Some("gr_rect") => {+ if let (Some((x1, y1)), Some((x2, y2))) = (xy(p.find("start")), xy(p.find("end"))) {+ for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {+ grow(&mut pts, x, y);+ }+ }+ }+ Some("gr_circle") => {+ if let (Some((cx, cy)), Some((ex, ey))) = (xy(p.find("center")), xy(p.find("end"))) {+ let r = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt() + width / 2.0;+ // Circumscribed 16-gon, so the polygon contains the circle.+ let rr = r / (std::f64::consts::PI / 16.0).cos();+ for k in 0..16 {+ let a = k as f64 * std::f64::consts::PI / 8.0;+ pts.push((cx + rr * a.cos(), cy + rr * a.sin()));+ }+ }+ }+ Some("gr_line") | Some("gr_arc") | Some("gr_curve") | Some("gr_bbox") => {+ exact = false;+ for tag in ["start", "mid", "end"] {+ if let Some((x, y)) = xy(p.find(tag)) {+ grow(&mut pts, x, y);+ }+ }+ }+ _ => exact = false,+ }+ }+ (pts, exact)+}++/// Andrew's monotone chain; returns the hull counter-clockwise, no repeated first point.+fn convex_hull(mut pts: Vec<(f64, f64)>) -> Vec<(f64, f64)> {+ pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)));+ pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9);+ if pts.len() < 3 {+ return pts;+ }+ let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);+ let mut lower: Vec<(f64, f64)> = Vec::new();+ for &p in &pts {+ while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], p) <= 0.0 {+ lower.pop();+ }+ lower.push(p);+ }+ let mut upper: Vec<(f64, f64)> = Vec::new();+ for &p in pts.iter().rev() {+ while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], p) <= 0.0 {+ upper.pop();+ }+ upper.push(p);+ }+ lower.pop();+ upper.pop();+ lower.extend(upper);+ lower } fn net_name_of(node: Option<&Sx>, board: &Board) -> String {@@ -505,6 +596,8 @@ fn pad_defs(fp: &Sx, board: &Board) -> Vec<PadDef> { offset, layers: pad.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default(), net: net_name_of(pad.find("net"), board),+ primitives: if pad.atom_at(3) == Some("custom") { custom_primitives(pad).0 } else { Vec::new() },+ custom_exact: pad.atom_at(3) != Some("custom") || custom_primitives(pad).1, }) }) .collect()@@ -541,6 +634,13 @@ fn fmt_mm_um(v: f64) -> i64 { /// Write the DSN for `board` (parsed from `text`). `nets`: route only these (None: every /// net with two or more pins); the other nets' pads and copper stay as obstacles. pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>) -> Result<Dsn, String> {+ write_dsn_with(board, text, name, rules, nets, true)+}++/// `reserve_planes`: write plane layers as `(type power)` (wiki #93). False lets the router+/// use them for signals, which completes more of a dense board at the cost of cutting+/// through the planes; the caller chooses, the reply says which it got.+pub fn write_dsn_with(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>, reserve_planes: bool) -> Result<Dsn, String> { let tree = parse_sexpr(text).ok_or("empty board text")?; let copper = pcb::copper_layers(board); if copper.is_empty() {@@ -603,14 +703,37 @@ pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Opt if w <= 0.0 || h <= 0.0 { continue; }- let exact = shape == "circle" || shape == "rect";+ // A custom pad is written as the hull of its anchor and drawn copper: a superset,+ // so the router cannot cross it; exact enough to leave off the approximated list+ // when every primitive was understood.+ let is_custom = shape == "custom" && !pad.primitives.is_empty();+ let exact = shape == "circle" || shape == "rect" || (is_custom && pad.custom_exact); if !exact { stats.approximated_pads.push(format!("{}.{} ({shape})", reference, pad.number)); } let tag = layer_tag(&on, &copper); let rot_key = if shape == "circle" { 0 } else { (pad.rot.rem_euclid(360.0) * 10.0).round() as i64 }; let (ox, oy) = pad.offset;- let offset_key = if ox == 0.0 && oy == 0.0 { String::new() } else { format!("_o{}x{}", fmt_mm_um(ox), fmt_mm_um(oy)) };+ let mut offset_key = if ox == 0.0 && oy == 0.0 { String::new() } else { format!("_o{}x{}", fmt_mm_um(ox), fmt_mm_um(oy)) };+ // Two custom pads with the same anchor size but different copper (a MOSFET's gate+ // and drain) must not share one padstack.+ let hull: Vec<(f64, f64)> = if is_custom {+ let mut all = pad.primitives.clone();+ all.extend([(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)]);+ convex_hull(all)+ } else {+ Vec::new()+ };+ if is_custom {+ let mut hash: u64 = 1469598103934665603;+ for (x, y) in &hull {+ for v in [(*x * 1000.0).round() as i64, (*y * 1000.0).round() as i64] {+ hash ^= v as u64;+ hash = hash.wrapping_mul(1099511628211);+ }+ }+ offset_key.push_str(&format!("_p{:08x}", hash as u32));+ } let kind_name = match shape.as_str() { "circle" => "Round", "rect" => "Rect",@@ -635,7 +758,8 @@ pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Opt } else { // Corners in the pad's own orientation (absolute in a board file), // y flipped afterwards. Oval, roundrect and the rest use the rect.- let corners = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];+ let rect = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];+ let corners: &[(f64, f64)] = if is_custom { &hull } else { &rect }; let pts: Vec<String> = corners.iter().map(|(cx, cy)| { let (rx, ry) = rotate(*cx, *cy, pad.rot); pt(rx + ox, ry + oy)@@ -794,8 +918,10 @@ pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Opt } let declared = board.layers.iter().any(|l| &l.name == layer && l.kind == "power"); let covered = board_area > 0.0 && plane_area.get(layer).map(|a| *a >= 0.5 * board_area).unwrap_or(false);- if declared || covered {+ if reserve_planes && (declared || covered) { stats.power_layers.push(layer.clone());+ } else if declared || covered {+ stats.notes.push(format!("{layer} is a plane but reservePlanes is false: signals may cross it")); } } if !stats.power_layers.is_empty() {@@ -1311,6 +1437,44 @@ mod tests { assert!(dsn.stats.notes.iter().any(|n| n.contains("In1.Cu, In3.Cu")), "{:?}", dsn.stats.notes); } + // Wiki #93: the ESC's MOSFET drain pad is a 1x1 mm anchor plus a 4.75 x 4.61 mm drawn+ // polygon. Writing only the anchor let Freerouting route /DRV_SHA straight across +VBAT.+ #[test]+ fn custom_pads_are_written_as_the_hull_of_their_real_copper() {+ let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")+ (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+ (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))+ (footprint "X:Q1" (layer "F.Cu") (at 10 10) (property "Reference" "Q1")+ (pad "1" smd custom (at -1.05 0) (size 1 1) (layers "F.Cu") (net "G")+ (options (clearance outline) (anchor rect))+ (primitives (gr_rect (start -0.5 -0.3) (end 0.2 0.3) (width 0) (fill yes))))+ (pad "5" smd custom (at 1.05 0) (size 1 1) (layers "F.Cu") (net "D")+ (options (clearance outline) (anchor rect))+ (primitives (gr_poly (pts (xy -2.375 -2.305) (xy 2.375 -2.305) (xy 2.375 -1.37) (xy 1.375 -1.37) (xy 1.375 1.37) (xy 2.375 1.37) (xy 2.375 2.305) (xy -2.375 2.305)) (width 0) (fill yes)))))+ (footprint "X:R1" (layer "F.Cu") (at 30 10) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "D"))+ (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "G")))+)"#;+ let board = pcb::parse_pcb_text(text, "x").unwrap();+ let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();+ let t = &dsn.text;+ // The drain pad's padstack reaches the polygon's full extent (+-2375 um in x, +-2305+ // in y), not the 1x1 mm anchor's +-500.+ let drain = t.lines().find(|l| l.contains("(pin Custom") && l.trim_end().ends_with(" 5 1050 0)")).unwrap_or_else(|| panic!("{t}"));+ let ps = drain.split_whitespace().nth(1).unwrap();+ let at = t.find(&format!("(padstack {ps}\n")).unwrap();+ let shape = t[at..].lines().nth(1).unwrap();+ assert!(shape.contains("-2375") && shape.contains("2375") && shape.contains("2305"), "{shape}");+ // Gate and drain share an anchor size but not copper, so not a padstack.+ let gate = t.lines().find(|l| l.contains("(pin Custom") && l.contains(" 1 -1050 0)")).unwrap_or_else(|| panic!("{t}"));+ assert_ne!(gate.split_whitespace().nth(1), drain.split_whitespace().nth(1), "{t}");+ // Every primitive was understood, so neither pad is reported as approximated.+ assert!(dsn.stats.approximated_pads.is_empty(), "{:?}", dsn.stats.approximated_pads);+ // The hull is a superset: every polygon vertex lies on or inside it.+ let hull = convex_hull(vec![(-2.375, -2.305), (2.375, -2.305), (2.375, -1.37), (1.375, -1.37), (1.375, 1.37), (2.375, 1.37), (2.375, 2.305), (-2.375, 2.305)]);+ assert_eq!(hull.len(), 4, "the notch is filled in: {hull:?}");+ }+ #[test] fn no_outline_falls_back_to_pads_with_a_note() { let text = r#"(kicad_pcb (version 20260206) (layers (0 "F.Cu" signal) (2 "B.Cu" signal))