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

Colby Knox ·f7d1e2b895 ·15d ago ·parent 391aeb9
12 files changed +339−32
SKILL.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,