← 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).

John Lauer ·b414d5b1f4 ·1mo ago ·parent 555e682
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))