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
Fix #133 (outline box from arcs and circles; ring courtyards: 2-degree sampling, interior probes), #130 (KICAD10_3RD_PARTY expands to Documents/KiCad/<ver>/3rdparty), #131 (kicad_sch_no_connect by pin or point); board model: copperPoints, circle pads, isolatedCopper {net, layer, at, areaMm2} (standard 0.1.1)
12 files changed
+339−32
SKILL.md+1bridge.json+2−1rust/crates/kicad-bridge/src/verbs_schematic.rs+38rust/crates/kicad-core/src/board_model.rs+42−11rust/crates/kicad-core/src/libraries.rs+26−5rust/crates/kicad-core/src/pcb.rs+47−4rust/crates/kicad-core/src/placement.rs+17−8rust/crates/kicad-core/src/schematic.rs+68rust/crates/kicad-core/tests/no_connect.rs+51rust/crates/kicad-core/tests/ring_courtyards.rs+38skills/kicad-board-model/SKILL.md+3−2skills/kicad-bridge-test/run_verb_tests.py+6−1SKILL.md+1@@ -330,6 +330,7 @@ KiCad 10 has no schematic API, so these verbs edit the `.kicad_sch` file on disk | `kicad_sch_place_symbol` | Place a library symbol with its pins and instances block; returns the new `uuid` | `filePath`, `libId` ("Device:R"), `reference`, `at` [x, y] mm, `rotation`?, `unit`?, `value`?, `footprint`?, `libraryPath`? | | `kicad_sch_wire` | A polyline wire | `filePath`, `points` [[x, y], ...] | | `kicad_sch_label` | A label | `filePath`, `text`, `at`, `kind` local, global, hierarchical, power, `rotation`? |+| `kicad_sch_no_connect` | A no-connect flag on an open pin, so ERC stops reporting it | `filePath`, `pin` (`U1.12` or `U1.PA10`) or `at` | | `kicad_sch_move` | Move a placed symbol by reference | `filePath`, `reference`, `to` [x, y] | | `kicad_sch_set_property` | Change or add a property (Value, Footprint, ...) | `filePath`, `reference`, `name`, `value` | | `kicad_sch_delete` | Delete any item by uuid | `filePath`, `uuid` |
bridge.json+2−1@@ -150,7 +150,8 @@ "kicad_copper_layers", "kicad_layers_in_use", "kicad_component_data",- "kicad_component_pins"+ "kicad_component_pins",+ "kicad_sch_no_connect" ], "statusVerb": "kicad_status", "timeouts": {
rust/crates/kicad-bridge/src/verbs_schematic.rs+38@@ -116,6 +116,20 @@ pub static VERBS: &[Verb] = &[ "Reference must stay unique on the sheet; the verb refuses a name already in use", ], },+ Verb {+ name: "kicad_sch_no_connect",+ summary: "Put a no-connect flag on a pin of a .kicad_sch (an intentionally open pin: a spare GPIO, an unused connector pin), so ERC stops reporting it (edits the file on disk).",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pin\": \"U1.PA10\" | \"U1.12\" | \"at\": [x_mm, y_mm]}",+ example: "kicad_sch_no_connect {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"pin\":\"U1.12\"}",+ hint: "pin names the symbol and its pin number (or pin name), and the flag lands on that pin's connection point, worked out from the symbol's position, angle and mirror; at places it at a point you already know. Writes (no_connect (at x y) (uuid ..)) as eeschema does. On disk only: reload the editor to see it. .bak written; the ERC delta shows the pin_not_connected error gone.",+ related: &["kicad_sch_label", "kicad_sch_wire", "kicad_run_erc", "kicad_netlist"],+ pitfalls: &[+ "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+ "a flag on a pin that is wired is an ERC error of its own (no_connect_connected): flag only open pins",+ "a flag must sit exactly on the pin's connection point; with at, a point off the pin is a dangling flag (ERC warns)",+ ],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -123,6 +137,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_sch_place_symbol" => edit(state, args, place_symbol), "kicad_sch_wire" => edit(state, args, wire), "kicad_sch_label" => edit(state, args, label),+ "kicad_sch_no_connect" => edit(state, args, no_connect), "kicad_sch_move" => edit(state, args, move_symbol), "kicad_sch_delete" => edit(state, args, delete), "kicad_sch_set_property" => edit(state, args, set_property),@@ -323,6 +338,29 @@ fn label(ctx: &mut Ctx) -> Result<Done, Value> { }) } +/// `kicad_sch_no_connect` (wiki #131): a flag on a pin's connection point, by pin or by point.+fn no_connect(ctx: &mut Ctx) -> Result<Done, Value> {+ let (at, pin) = if let Some(spec) = arg_any(ctx.args, &["pin", "pins"]) {+ let (reference, want) = spec.trim().split_once('.').ok_or_else(|| fail("bad_arg", format!("pin {spec} is not Ref.Pin"), "Name the symbol and its pin: U1.12 or U1.PA10."))?;+ let points = ctx.sch.pin_points(reference).map_err(|e| op_fail("not_found", e, "kicad_netlist {parts:true} lists every part with its pins."))?;+ // the pin by number, else by name+ let hit = points.iter().find(|(n, _)| n == want).map(|(_, p)| *p).or_else(|| {+ let names = ctx.sch.pin_names(reference);+ names.iter().find(|(_, name)| name.eq_ignore_ascii_case(want)).and_then(|(num, _)| points.iter().find(|(n, _)| n == num).map(|(_, p)| *p))+ });+ let at = hit.ok_or_else(|| fail("pin_not_found", format!("{reference} has no pin {want}"), "Pins are matched by number first, then by name; kicad_netlist {parts:true} lists them."))?;+ (at, Some(spec.trim().to_string()))+ } else {+ (point(ctx.args, "at")?, None)+ };+ let uuid = ctx.sch.add_no_connect(at).map_err(|e| op_fail("edit_failed", e, "Nothing was written."))?;+ Ok(Done {+ output: format!("Added a no-connect flag{} at ({}, {}) in {}", pin.as_ref().map(|p| format!(" on {p}")).unwrap_or_default(), schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),+ uuid,+ extra: json!({"at": [at.0, at.1], "pin": pin}),+ })+}+ fn move_symbol(ctx: &mut Ctx) -> Result<Done, Value> { let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol to move, e.g. R5."))?.trim().to_string(); let to = point(ctx.args, "to")?;
rust/crates/kicad-core/src/board_model.rs+42−11@@ -325,6 +325,8 @@ struct Pad { kicad_shape: String, kind: String, anchor: Option<P>,+ /// a custom pad's real copper outline (board coordinates), when it is one drawn polygon over the anchor+ copper_points: Option<Vec<P>>, } #[derive(Clone, Debug)]@@ -555,9 +557,10 @@ fn pad_of(cx: &Ctx, pad: &Sx, place: &Place, owner: &str) -> Option<Pad> { let layer = if copper.len() > 1 || npth || layers_of(pad).iter().any(|l| l == "*.Cu") { "*.Cu".to_string() } else { copper[0].clone() }; let copper = if npth && copper.is_empty() { cx.copper.clone() } else { copper }; let net = if npth { String::new() } else { cx.net(pad) };- let base = Pad { owner: owner.to_string(), name, at: center, size, rotation: angle, layer, copper, drill, net, shape: "rect", kicad_shape: kshape.clone(), kind, anchor: None };+ let base = Pad { owner: owner.to_string(), name, at: center, size, rotation: angle, layer, copper, drill, net, shape: "rect", kicad_shape: kshape.clone(), kind, anchor: None, copper_points: None }; Some(match kshape.as_str() {- "circle" | "oval" => Pad { shape: "round", ..base },+ "circle" => Pad { shape: "circle", ..base },+ "oval" => Pad { shape: "round", ..base }, "roundrect" => Pad { shape: "roundrect", ..base }, "trapezoid" => { // its widest extent: the size plus the delta on each side@@ -600,6 +603,18 @@ fn custom_pad(pad: &Sx, base: Pad, center: P, size: P, angle: f64) -> Pad { _ => {} } }+ // the real outline, when it is exact: one filled polygon primitive that covers the anchor (the usual+ // custom pad); several shapes would need a union, so they keep only the rectangle+ let polys: Vec<&Sx> = pad.find("primitives").and_then(Sx::as_list).into_iter().flatten().skip(1).collect();+ let copper_points = match polys.as_slice() {+ [one] if one.tag() == Some("gr_poly") && (filled(one) || one.find("fill").is_none()) => {+ let pts = pts_of(one.find("pts"));+ let anchor = if anchor_round { vec![(0.0, 0.0)] } else { vec![(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)] };+ let covers = pts.len() >= 3 && anchor.iter().all(|a| in_poly(*a, &pts) || pts.iter().enumerate().any(|(k, q)| dist_seg(*a, *q, pts[(k + 1) % pts.len()]) < 1e-6));+ covers.then(|| pts.iter().map(|q| add(center, rotate(*q, angle))).collect())+ }+ _ => None,+ }; let world: Vec<P> = local.iter().map(|p| add(center, rotate(*p, angle))).collect(); let (x0, x1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0))); let (y0, y1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));@@ -609,6 +624,7 @@ fn custom_pad(pad: &Sx, base: Pad, center: P, size: P, angle: f64) -> Pad { rotation: 0.0, shape: "rect", anchor: Some(center),+ copper_points, ..base } }@@ -1262,7 +1278,7 @@ fn touch(x: &Cu, y: &Cu) -> bool { fn pad_cu(p: &Pad, idx: &dyn Fn(&str) -> Option<usize>) -> Cu { let layers: Vec<usize> = p.copper.iter().filter_map(|l| idx(l)).collect(); let (hx, hy) = (p.size.0 / 2.0, p.size.1 / 2.0);- let shape = if p.shape == "round" {+ let shape = if p.shape == "round" || p.shape == "circle" { let (r, half) = if hx >= hy { (hy, (hx - hy, 0.0)) } else { (hx, (0.0, hy - hx)) }; let d = rotate(half, p.rotation); Shape::Capsule { a: (p.at.0 - d.0, p.at.1 - d.1), b: add(p.at, d), r }@@ -1289,7 +1305,8 @@ fn capsule(a: P, b: P, r: f64, layers: Vec<usize>) -> Cu { struct Connectivity { unconnected: usize, split: Vec<Value>,- isolated: Vec<Value>,+ /// (net, layer, a point on it, area mm2), y up+ isolated: Vec<(String, String, P, f64)>, } /// Connections still to make, from the copper: per net, (groups of touching copper) - 1. Pours count@@ -1301,7 +1318,7 @@ fn unrouted(m: &Model) -> Connectivity { let all: Vec<usize> = (0..m.copper.len()).collect(); // each piece of copper with what it is: Some(pad name), None for a track or via, Pour(layer) #[derive(Clone)]- enum What { Pad(String), Wire, Pour(String) }+ enum What { Pad(String), Wire, Pour(String, f64, P) } let mut by_net: BTreeMap<&str, Vec<(Cu, What)>> = BTreeMap::new(); for p in &m.pads { if !p.net.is_empty() {@@ -1334,7 +1351,7 @@ fn unrouted(m: &Model) -> Connectivity { let islands: Vec<&Vec<P>> = if g.fills.is_empty() { vec![&g.points] } else { g.fills.iter().collect() }; for pts in islands { if pts.len() >= 3 {- by_net.entry(&g.net).or_default().push((Cu { bbox: bbox_of(pts, 0.0), shape: Shape::Poly { pts: pts.clone() }, layers: vec![l] }, What::Pour(g.layer.clone())));+ by_net.entry(&g.net).or_default().push((Cu { bbox: bbox_of(pts, 0.0), shape: Shape::Poly { pts: pts.clone() }, layers: vec![l] }, What::Pour(g.layer.clone(), shoelace(pts).abs(), pts[0]))); } } }@@ -1364,10 +1381,17 @@ fn unrouted(m: &Model) -> Connectivity { } let mut live: Vec<Vec<String>> = Vec::new(); for members in groups.values() {- let only_pour = members.iter().all(|i| matches!(items[*i].1, What::Pour(_)));+ let only_pour = members.iter().all(|i| matches!(items[*i].1, What::Pour(..))); if only_pour {- let layers: BTreeSet<&str> = members.iter().filter_map(|i| if let What::Pour(l) = &items[*i].1 { Some(l.as_str()) } else { None }).collect();- out.isolated.push(json!({"net": net, "layers": layers}));+ // pours join only on a shared layer, so an island is on one layer+ let (mut layer, mut area, mut at) = (String::new(), 0.0, (0.0, 0.0));+ for i in members {+ if let What::Pour(l, a, p) = &items[*i].1 {+ if layer.is_empty() { layer = l.clone(); at = *p; }+ area += a;+ }+ }+ out.isolated.push((net.to_string(), layer, at, area)); continue; } live.push(members.iter().filter_map(|i| if let What::Pad(p) = &items[*i].1 { Some(p.clone()) } else { None }).collect());@@ -1467,6 +1491,9 @@ fn pads_json(m: &Model, o: &Opts) -> Value { if let Some(a) = p.anchor { j["anchor"] = pj(a, o); }+ if let Some(c) = &p.copper_points {+ j["copperPoints"] = json!(c.iter().map(|q| pa(*q, o)).collect::<Vec<_>>());+ } if p.layer == "*.Cu" && p.copper.len() != m.copper.len() { j["layers"] = json!(p.copper); }@@ -1546,7 +1573,7 @@ fn to_json(m: &Model, file: Option<&str>, revision: &str, o: &Opts) -> Value { out["keepouts"] = Value::Array(m.keepouts.iter().map(|k| with_owner(json!({"layers": k.layers, "points": k.points.iter().map(|p| pa(*p, o)).collect::<Vec<_>>(), "rules": k.rules}), &k.owner)).collect()); } if !conn.isolated.is_empty() {- out["isolatedCopper"] = json!(conn.isolated);+ out["isolatedCopper"] = json!(conn.isolated.iter().map(|(n, l, at, a)| json!({"net": n, "layer": l, "at": pj(*at, o), "areaMm2": r6(*a)})).collect::<Vec<_>>()); } if !unfilled.is_empty() { out["unfilledZones"] = json!(unfilled);@@ -1645,6 +1672,10 @@ mod tests { assert_eq!(p["anchor"], json!({"x": 99.275, "y": -50.0})); assert_eq!(p["layer"], "B.Cu"); assert_eq!(v["components"][0]["side"], "bottom");+ // its real outline: the pentagon, which covers the anchor+ let c = p["copperPoints"].as_array().unwrap();+ assert_eq!(c.len(), 5);+ assert_eq!(c[0], json!([100.0 - 0.725 + 1.0, -50.0])); } #[test]@@ -1716,7 +1747,7 @@ mod tests { (zone (net \"G\") (layer \"F.Cu\") (polygon (pts (xy -2 -2) (xy 30 -2) (xy 30 2) (xy -2 2)))\ (filled_polygon (layer \"F.Cu\") (pts (xy -2 -2) (xy 5 -2) (xy 5 2) (xy -2 2))) (filled_polygon (layer \"F.Cu\") (pts (xy 20 -2) (xy 30 -2) (xy 30 2) (xy 20 2))))"); assert_eq!(v["unconnected"], json!(0));- assert_eq!(v["isolatedCopper"], json!([{"net": "G", "layers": ["F.Cu"]}]));+ assert_eq!(v["isolatedCopper"], json!([{"net": "G", "layer": "F.Cu", "at": {"x": 20.0, "y": 2.0}, "areaMm2": 40.0}])); } #[test]
rust/crates/kicad-core/src/libraries.rs+26−5@@ -455,12 +455,21 @@ pub fn expand_kicad_uri(uri: &str, ctx: &LibCtx) -> Option<String> { if let Some(docs) = &ctx.user_dir { subs.push(("KICAD_USER_TEMPLATE_DIR".into(), Some(norm_path(&docs.join("template"))))); }- if let Some(cfg) = &ctx.config_dir {- if !ctx.version.is_empty() {- subs.push(("KICAD_3RD_PARTY".into(), Some(norm_path(&cfg.join("3rdparty")))));- }+ // Where the Plugin and Content Manager installs (wiki #130): KICAD<n>_3RD_PARTY, or the older+ // KICAD_3RD_PARTY spelling, from Configure Paths; else Documents/KiCad/<ver>/3rdparty, KiCad's own+ // default (the same order model_check uses).+ let third = ctx.env_overrides.get(&format!("KICAD{major}_3RD_PARTY")).cloned()+ .or_else(|| ctx.env_overrides.get("KICAD_3RD_PARTY").cloned())+ .or_else(|| ctx.user_dir.as_ref().map(|u| norm_path(&u.join("3rdparty"))))+ .or_else(|| ctx.config_dir.as_ref().filter(|_| !ctx.version.is_empty()).map(|c| norm_path(&c.join("3rdparty"))));+ if let Some(t) = third {+ subs.push((format!("KICAD{major}_3RD_PARTY"), Some(t.clone())));+ subs.push(("KICAD_3RD_PARTY".into(), Some(t))); } for (k, v) in &ctx.env_overrides {+ if k.ends_with("3RD_PARTY") {+ continue; // resolved above, so either spelling reaches both names+ } subs.retain(|(name, _)| name != k); subs.push((k.clone(), Some(v.clone()))); }@@ -1047,6 +1056,17 @@ mod tests { } } + #[test]+ fn pcm_libraries_expand_to_the_3rdparty_folder() {+ // wiki #130: KiCad 10's PCM writes ${KICAD10_3RD_PARTY} into the global sym-lib-table+ let uri = "${KICAD10_3RD_PARTY}/symbols/inc_adom_wiki_x/lib.kicad_sym";+ assert_eq!(expand_kicad_uri(uri, &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/symbols/inc_adom_wiki_x/lib.kicad_sym");+ assert_eq!(expand_kicad_uri("${KICAD_3RD_PARTY}/a.kicad_sym", &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/a.kicad_sym");+ let mut c = ctx();+ c.env_overrides.insert("KICAD_3RD_PARTY".into(), "D:/pcm".into());+ assert_eq!(expand_kicad_uri(uri, &c).unwrap(), "D:/pcm/symbols/inc_adom_wiki_x/lib.kicad_sym", "the older spelling from Configure Paths applies to both");+ }+ #[test] fn tokenizer_handles_quotes_and_nesting() { let s = parse_sexp(r#"(a "b c" (d "e\"f") g)"#).unwrap();@@ -1143,8 +1163,9 @@ mod tests { Some("C:/Users/john/Documents/KiCad/10.0/template/t") ); assert_eq!(+ // Documents/KiCad/<ver>/3rdparty, where KiCad's PCM installs (checked on a KiCad 10.0.4 box, wiki #130) expand_kicad_uri("${KICAD_3RD_PARTY}/footprints/x.pretty", &c).as_deref(),- Some("C:/Users/john/AppData/Roaming/kicad/10.0/3rdparty/footprints/x.pretty")+ Some("C:/Users/john/Documents/KiCad/10.0/3rdparty/footprints/x.pretty") ); // Plain paths pass through normalised. assert_eq!(expand_kicad_uri("C:\\a\\.\\b\\..\\c.kicad_sym", &c).as_deref(), Some("C:/a/c.kicad_sym"));
rust/crates/kicad-core/src/pcb.rs+47−4@@ -538,8 +538,11 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> { for node in fp.find_all("fp_arc") { if on_courtyard(node) { if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {- out.push((a, m.clone()));- out.push((m, b));+ // every 2 degrees, like the board model: two chords cut a large arc short (wiki #133)+ let pts = arc_points(&a, &m, &b);+ for w in pts.windows(2) {+ out.push((w[0].clone(), w[1].clone()));+ } } } }@@ -557,7 +560,8 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> { if on_courtyard(node) { if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) { let r = dist((c.x, c.y), (e.x, e.y));- let n = 24;+ // 2 degrees: a 24-gon's chords cut a 24 mm ring's hole 0.2 mm short (wiki #133)+ let n = 180; let at = |k: usize| { let t = std::f64::consts::TAU * k as f64 / n as f64; pt(c.x + r * t.cos(), c.y + r * t.sin())@@ -583,6 +587,33 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> { out } +/// A three-point arc as points every 2 degrees from start through mid to end (start and end exact);+/// collinear points are a straight line.+pub fn arc_points(a: &Point, m: &Point, b: &Point) -> Vec<Point> {+ let d = 2.0 * (a.x * (m.y - b.y) + m.x * (b.y - a.y) + b.x * (a.y - m.y));+ if d.abs() < 1e-12 {+ return vec![a.clone(), b.clone()];+ }+ let (a2, m2, b2) = (a.x * a.x + a.y * a.y, m.x * m.x + m.y * m.y, b.x * b.x + b.y * b.y);+ let cx = (a2 * (m.y - b.y) + m2 * (b.y - a.y) + b2 * (a.y - m.y)) / d;+ let cy = (a2 * (b.x - m.x) + m2 * (a.x - b.x) + b2 * (m.x - a.x)) / d;+ let r = dist((cx, cy), (a.x, a.y));+ let ang = |p: &Point| (p.y - cy).atan2(p.x - cx);+ let tau = std::f64::consts::TAU;+ let (s, mid, e) = (ang(a), ang(m), ang(b));+ let ccw = |x: f64, y: f64| (y - x).rem_euclid(tau);+ // the sweep from start to end that passes through mid, signed+ let sweep = if ccw(s, mid) < ccw(s, e) { ccw(s, e) } else { -(tau - ccw(s, e)) };+ let n = ((sweep.abs().to_degrees() / 2.0).ceil() as usize).max(1);+ let mut out: Vec<Point> = (0..=n).map(|k| {+ let t = s + sweep * k as f64 / n as f64;+ Point { x: cx + r * t.cos(), y: cy + r * t.sin() }+ }).collect();+ out[0] = a.clone();+ out[n] = b.clone();+ out+}+ fn point_of(node: Option<&Sx>) -> Option<Point> { let n = node?; Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })@@ -662,11 +693,23 @@ fn extract_dimensions(tree: &Sx) -> Option<Dimensions> { if !on_edge(node) { continue; }- for coord in ["start", "end", "center", "mid"] {+ for coord in ["start", "end", "mid"] { if let Some(p) = point_of(node.find(coord)) { update(p.x, p.y); } }+ // an arc reaches as far as its sweep goes, a circle its radius all round (wiki #133:+ // one 340 degree edge arc read only by its three points left the box 100 mm short)+ if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {+ for p in arc_points(&a, &m, &b) {+ update(p.x, p.y);+ }+ }+ if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {+ let r = dist((c.x, c.y), (e.x, e.y));+ update(c.x - r, c.y - r);+ update(c.x + r, c.y + r);+ } if let Some(pts) = node.find("pts") { for xy in pts.find_all("xy") { if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {
rust/crates/kicad-core/src/placement.rs+17−8@@ -208,17 +208,26 @@ fn on_boundary(pt: (f64, f64), edges: &[Edge]) -> bool { }) } -/// Points that sample an outline's region: its corners, the middle of each edge, and the-/// centre of its corners.+/// Points that sample an outline's region: its corners, the middle of each edge, and a point a hair+/// inside the outline beside each edge's middle (wiki #133). Not the centre of the corners: a ring+/// courtyard's centre is in its hole, and a ray from there skims the polygon's vertices. fn probes(e: &[Edge]) -> Vec<(f64, f64)> {- let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 2 + 1);+ const D: f64 = 1e-4;+ let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 3); for &(a, b) in e {+ let m = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); v.push(a);- v.push(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0));- }- if !e.is_empty() {- let n = e.len() as f64;- v.push((e.iter().map(|(a, _)| a.0).sum::<f64>() / n, e.iter().map(|(a, _)| a.1).sum::<f64>() / n));+ v.push(m);+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l = (dx * dx + dy * dy).sqrt();+ if l > 1e-9 {+ let n = (-dy / l * D, dx / l * D);+ for q in [(m.0 + n.0, m.1 + n.1), (m.0 - n.0, m.1 - n.1)] {+ if inside(q, e) {+ v.push(q);+ }+ }+ } } v }
rust/crates/kicad-core/src/schematic.rs+68@@ -722,6 +722,74 @@ impl Schematic { Ok(uuid) } + /// Where each pin of a placed symbol connects, in sheet millimetres (y down), every unit placed+ /// under that reference: [(pin number, (x, y))]. Library pins are y up; the symbol's angle turns+ /// them counterclockwise as seen, then its mirror flips them (KiCad's order).+ pub fn pin_points(&self, reference: &str) -> Result<Vec<(String, (f64, f64))>, String> {+ let mut out = Vec::new();+ let mut found = false;+ for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {+ found = true;+ let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();+ let Some(lib) = self.lib_symbol(&lib_key) else { return Err(format!("{reference}: its library symbol {lib_key} is not in the sheet's lib_symbols")) };+ let base = lib_key.rsplit(':').next().unwrap_or(&lib_key).to_string();+ let unit = inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32;+ let at = inst.child("at").ok_or("symbol has no (at ...)")?;+ let (sx, sy, rot) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0), at.num(2).unwrap_or(0.0));+ let mirror = inst.child_value("mirror");+ let (sin, cos) = rot.to_radians().sin_cos();+ for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+ let Some(name) = sub.arg(0) else { continue };+ let Some(u) = sub_unit(&name, &base) else { continue };+ if u != 0 && u != unit {+ continue;+ }+ for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+ let (Some(num), Some(pat)) = (pin.child_value("number"), pin.child("at")) else { continue };+ let (px, py) = (pat.num(0).unwrap_or(0.0), pat.num(1).unwrap_or(0.0));+ let (mut qx, mut qy) = (px * cos - py * sin, px * sin + py * cos);+ match mirror.as_deref() {+ Some("x") => qy = -qy,+ Some("y") => qx = -qx,+ _ => {}+ }+ let r = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+ out.push((num, (r(sx + qx), r(sy - qy))));+ }+ }+ }+ if !found {+ return Err(format!("no symbol with reference {reference} on this sheet"));+ }+ Ok(out)+ }++ /// A placed symbol's pins as (number, name), from its library symbol.+ pub fn pin_names(&self, reference: &str) -> Vec<(String, String)> {+ let mut out = Vec::new();+ for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {+ let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();+ let Some(lib) = self.lib_symbol(&lib_key) else { continue };+ for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+ for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+ if let (Some(num), Some(name)) = (pin.child_value("number"), pin.child_value("name")) {+ if !out.iter().any(|(n, _): &(String, String)| *n == num) {+ out.push((num, name));+ }+ }+ }+ }+ }+ out+ }++ /// A no-connect flag at `at` (on a pin's connection point): `(no_connect (at x y) (uuid ..))`.+ pub fn add_no_connect(&mut self, at: (f64, f64)) -> Result<String, String> {+ let uuid = new_uuid();+ self.insert_item(Node::list("no_connect", vec![Node::list("at", vec![Node::number(at.0), Node::number(at.1)]), Node::list("uuid", vec![Node::quoted(&uuid)])]));+ Ok(uuid)+ }+ /// Move a symbol (and its fields with it) so its anchor lands on `to`. pub fn move_symbol(&mut self, reference: &str, to: (f64, f64)) -> Result<(String, (f64, f64)), String> { let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;
rust/crates/kicad-core/tests/no_connect.rsadded+51@@ -0,0 +1,51 @@+//! Wiki #131: pin connection points, checked against KiCad's own demo sheets: every no-connect flag+//! KiCad placed sits on a pin the bridge computes (rotated and mirrored symbols included).+use kicad_core::schematic::Schematic;+use std::path::PathBuf;++#[test]+fn every_demo_no_connect_flag_lands_on_a_computed_pin() {+ let dir = PathBuf::from(std::env::var("KICAD_DEMO_SCH").unwrap_or_else(|_| format!("{}/../../../../fixtures/sch", env!("CARGO_MANIFEST_DIR"))));+ let Ok(rd) = std::fs::read_dir(&dir) else { eprintln!("no demo sheets at {}; skipped", dir.display()); return };+ let (mut flags, mut hit) = (0, 0);+ let mut misses = Vec::new();+ for e in rd.flatten().filter(|e| e.path().extension().map_or(false, |x| x == "kicad_sch")) {+ let text = std::fs::read_to_string(e.path()).unwrap();+ let sch = Schematic::parse(&text).unwrap();+ let refs: std::collections::BTreeSet<String> = sch.symbols().into_iter().map(|s| s.reference).collect();+ let mut pins = Vec::new();+ for r in &refs {+ if let Ok(p) = sch.pin_points(r) {+ pins.extend(p.into_iter().map(|(n, at)| (r.clone(), n, at)));+ }+ }+ for line in text.lines().collect::<Vec<_>>().windows(2) {+ if line[0].trim() == "(no_connect" {+ let nums: Vec<f64> = line[1].trim().trim_start_matches("(at").trim_end_matches(')').split_whitespace().filter_map(|t| t.parse().ok()).collect();+ if nums.len() < 2 { continue }+ flags += 1;+ if pins.iter().any(|(_, _, (x, y))| (x - nums[0]).abs() < 1e-3 && (y - nums[1]).abs() < 1e-3) {+ hit += 1;+ } else {+ misses.push(format!("{}: ({}, {})", e.file_name().to_string_lossy(), nums[0], nums[1]));+ }+ }+ }+ }+ eprintln!("{hit} of {flags} no-connect flags on a computed pin");+ assert!(flags == 0 || hit == flags, "{} missed: {:?}", misses.len(), &misses[..misses.len().min(8)]);+}++#[test]+fn a_flag_lands_on_a_named_pin_of_the_fixture_sheet() {+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../tour-pack-rp2040/template/rp2040-breakout.kicad_sch");+ let mut sch = Schematic::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();+ let pins = sch.pin_points("U1").unwrap();+ let (_, at) = pins.iter().find(|(n, _)| n == "1").expect("U1 pin 1");+ let uuid = sch.add_no_connect(*at).unwrap();+ let text = sch.to_text();+ let again = Schematic::parse(&text).unwrap();+ assert!(text.contains(&format!("(uuid \"{uuid}\")")));+ assert!(again.pin_points("U1").unwrap().iter().any(|(_, p)| p == at));+ assert!(text.contains("(no_connect"));+}
rust/crates/kicad-core/tests/ring_courtyards.rsadded+38@@ -0,0 +1,38 @@+//! Wiki #133: concentric ring courtyards (two F.CrtYd circles each, an annulus) do not overlap, and a+//! board edge drawn as one large arc bounds the outline by its real extent.+use kicad_core::{pcb, placement};++fn ring(reference: &str, d_in: f64, d_out: f64) -> String {+ format!("(footprint \"x:{reference}\" (layer \"F.Cu\") (at 100 100) (property \"Reference\" \"{reference}\")\+ (fp_circle (center 0 0) (end {} 0) (layer \"F.CrtYd\") (stroke (width 0.05)))\+ (fp_circle (center 0 0) (end {} 0) (layer \"F.CrtYd\") (stroke (width 0.05))))", d_in / 2.0, d_out / 2.0)+}++#[test]+fn nested_ring_courtyards_do_not_overlap() {+ // the sensor from the report: electrodes and guards, all on one centre+ let fps = [ring("E2", 23.0, 31.95), ring("G2", 43.8, 47.7), ring("E4", 48.0, 56.95), ring("G3", 57.25, 59.25)].concat();+ let text = format!("(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal)) {fps})");+ let board = pcb::parse_pcb_text(&text, "t.kicad_pcb").unwrap();+ let o = placement::overlaps(&placement::placements(&board));+ assert!(o.is_empty(), "{o:?}");+}++#[test]+fn a_large_edge_arc_bounds_the_outline_by_its_bulge() {+ // a 146 mm ring edge drawn as one arc of about 340 degrees: start and end low, mid at the top+ let r = 73.0_f64;+ let (cx, cy) = (100.0, 110.0);+ let a = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+ let (s, e, m) = (a(100.0), a(80.0), a(270.0));+ let text = format!("(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (25 \"Edge.Cuts\" user))\+ (gr_arc (start {} {}) (mid {} {}) (end {} {}) (layer \"Edge.Cuts\") (stroke (width 0.1)))\+ (gr_line (start {} {}) (end {} {}) (layer \"Edge.Cuts\") (stroke (width 0.1)))\+ (gr_circle (center {cx} {cy}) (end {} {cy}) (layer \"Edge.Cuts\") (stroke (width 0.1))))",+ s.0, s.1, m.0, m.1, e.0, e.1, e.0, e.1, s.0, s.1, cx + 22.5);+ let board = pcb::parse_pcb_text(&text, "t.kicad_pcb").unwrap();+ let o = placement::outline(&board).unwrap();+ assert!((o.min_x - (cx - r)).abs() < 0.01 && (o.max_x - (cx + r)).abs() < 0.01, "{o:?}");+ assert!((o.min_y - (cy - r)).abs() < 0.01, "{o:?}");+}+
skills/kicad-board-model/SKILL.md+3−2@@ -24,10 +24,11 @@ KiCad), which AI Flow reads: both stay. |---|---| | Millimetres, absolute, **y up** | KiCad's y is negated. `frame:"kicad"` keeps KiCad's y down. Angles are KiCad's own (counterclockwise as seen) in both frames | | KiCad layer names | `F.Cu`, `In1.Cu`..`In30.Cu`, `B.Cu`; `*.Cu` is a pad on every copper layer (a partial set adds `layers`) |-| Custom pads are their bounding rectangle | `shape:"rect"`, rotation 0, `kicadShape:"custom"`, `anchor` = KiCad's anchor (where tracks land) |+| Pad shapes | `circle`, `round` (KiCad's oval: a stadium), `rect`, `roundrect`; `kicadShape` keeps KiCad's own name when it differs |+| Custom pads are their bounding rectangle | `shape:"rect"`, rotation 0, `kicadShape:"custom"`, `anchor` = KiCad's anchor (where tracks land), and `copperPoints` (the real outline) when the copper is one drawn polygon over the anchor; pads drawn from several shapes keep only the rectangle | | Net-less copper is kept | `net:""`: free copper shapes, pours on no net; footprint copper carries `ref` | | Outline with arcs | `outlineEdges` (line, or arc with center, radius, angle1 to angle2 counterclockwise), `outline` = their start points, `cutouts` for inner loops, `keepouts` for rule areas |-| `unconnected` from the copper | pads, tracks, arcs, vias and the poured copper joined where they touch on a shared layer. `splitNets` names each piece's pads (what to join). An isolated pour island is `isolatedCopper`, not a connection to make (KiCad warns about it the same way) |+| `unconnected` from the copper | pads, tracks, arcs, vias and the poured copper joined where they touch on a shared layer. `splitNets` names each piece's pads (what to join). An isolated pour island is `isolatedCopper` (`{net, layer, at, areaMm2}`), not a connection to make (KiCad's DRC warns about it the same way) | | `revision` moves only with copper, parts or outline | silkscreen, text and refills leave it alone; the order KiCad lists items in does not matter | Checked against KiCad's own DRC on six of KiCad 10's demo boards (StickHub, CM5 Minima, RoyalBlue54L
skills/kicad-bridge-test/run_verb_tests.py+6−1@@ -103,6 +103,8 @@ CHECKS = { and any(p.get("pins") for p in (r.get("parts") or [])), "board_summary": lambda r: ok(r) and (r.get("summary") or {}).get("components", 0) > 0, "count_list": lambda r: ok(r) and (r.get("count") or 0) > 0,+ "no_connect_placed": lambda r: ok(r) and bool(r.get("uuid")) and isinstance(r.get("at"), list) and r.get("pin") == "U1.1",+ "net_list": lambda r: ok(r) and isinstance(r.get("nets"), list) and r.get("count") == len(r["nets"]), "layers_in_use": lambda r: ok(r) and len(r.get("copperLayersInUse") or []) > 0, "component_data": lambda r: ok(r) and (r.get("component") or {}).get("pads", 0) > 0, "component_pins": lambda r: ok(r) and (r.get("count") or 0) > 0,@@ -175,7 +177,8 @@ def matrix(w: dict) -> dict: note="3D model bounds from the files the footprints name; every part is a body or in noBody"), "netlist": dict(phase=2, args={"filePath": S, "parts": True}, check="std_netlist", timeout=120), "board_summary": dict(phase=2, args={"filePath": B, "source": "file"}, check="board_summary"),- "list_nets": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+ "list_nets": dict(phase=2, args={"filePath": B, "source": "file"}, check="net_list",+ note="the rp2040 fixture board carries no nets: an empty list is the right answer"), "list_designators": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"), "copper_layers": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"), "layers_in_use": dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),@@ -203,6 +206,8 @@ def matrix(w: dict) -> dict: note="fixture copy only; the .bak is removed with the fixture dir"), "sch_wire": dict(phase=2.2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150), "sch_label": dict(phase=2.2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),+ "sch_no_connect": dict(phase=2.2, args={"filePath": S, "pin": "U1.1"}, check="no_connect_placed", timeout=150,+ note="wiki #131: the flag lands on U1 pin 1's connection point, worked out from the symbol"), "sch_set_property": dict(phase=2.3, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150), "sch_move": dict(phase=2.4, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150), "sch_delete": dict(phase=2.5, args={"__special": "sch_delete"}, timeout=150,