← Commit history
rust/Cargo.lock+4−4
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.4.0"+version = "0.4.1" dependencies = [  "roxmltree",  "serde_json",@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.72"+version = "1.0.73" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.72"+version = "1.0.73" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.72"+version = "1.0.73" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.72"+version = "1.0.73" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/catalog.rs+2−2
@@ -147,9 +147,9 @@ pub static VERBS: &[Verb] = &[         name: "kicad_render_board",         summary: "Render a 3D image (PNG/JPG) of a board from a side via kicad-cli pcb render, headless, no KiCad window.",         mechanism: Mechanism::Cli, risk: "write", timeout_sec: 600,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"side\"?: \"top\"|\"bottom\"|\"left\"|\"right\"|\"front\"|\"back\", \"width\"?: 1600, \"height\"?: 900, \"quality\"?: \"basic\"|\"high\", \"background\"?: \"default\"|\"transparent\"|\"opaque\", \"zoom\"?: 1.0, \"perspective\"?: bool, \"format\"?: \"png\"|\"jpg\"}",+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"side\"?: \"top\"|\"bottom\"|\"left\"|\"right\"|\"front\"|\"back\", \"width\"?: 1600, \"height\"?: 900, \"quality\"?: \"basic\"|\"high\", \"background\"?: \"default\"|\"transparent\"|\"opaque\", \"zoom\"?: 1.0, \"perspective\"?: bool, \"format\"?: \"png\"|\"jpg\", \"ref\"?: \"J1\" | \"refs\"?: [\"J1\", \"U3\"], \"marginMm\"?: 2, \"rotate\"?: \"-45,0,45\"}",         example: "kicad_render_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"side\":\"top\"}",-        hint: "One call per side (top and bottom for a review). quality:\"high\" raytraces and is much slower. For a flat picture of chosen layers on a dark background use kicad_export_svg {pageSize:\"board\", background:\"#333333\"} instead.",+        hint: "One call per side (top and bottom for a review). ref or refs frames those parts: the camera pivots on their pads' centre and zooms to fit them with marginMm around (data.framed says the pivot and zoom; zoom overrides). rotate \"-45,0,45\" gives an isometric view. quality:\"high\" raytraces and is much slower. For a flat picture of chosen layers on a dark background use kicad_export_svg {pageSize:\"board\", background:\"#333333\"} instead.",         related: &["kicad_export_svg", "kicad_export_step", "kicad_model_check"],         pitfalls: &["Missing 3D models render as bare pads; run kicad_model_check first."],     },
rust/crates/kicad-bridge/src/verbs.rs+34−2
@@ -564,10 +564,42 @@ fn render_board(state: &mut State, args: &Value) -> Value {     let w = width.to_string();     let h = height.to_string();     let mut argv: Vec<String> = vec!["pcb".into(), "render".into(), "--output".into(), out_s.clone(), "--side".into(), side.into(), "--quality".into(), quality.into(), "--background".into(), background.into(), "--width".into(), w, "--height".into(), h];-    if let Some(z) = args.get("zoom").and_then(Value::as_f64) {+    // ref / refs: frame those parts (wiki #105), pivot from the board's centre and a zoom that fits them+    let refs: Vec<String> = match (args.get("refs").and_then(Value::as_array), arg_str(args, "ref")) {+        (Some(a), _) => a.iter().filter_map(Value::as_str).map(String::from).collect(),+        (None, Some(r)) => vec![r.to_string()],+        _ => Vec::new(),+    };+    let mut framed = Value::Null;+    let mut zoom = args.get("zoom").and_then(Value::as_f64);+    if !refs.is_empty() {+        let text = match std::fs::read_to_string(&path) {+            Ok(t) => t,+            Err(e) => return fail("file_unreadable", format!("{}: {e}", path.display()), "The board file must be readable."),+        };+        let model = match kicad_core::board_model::build(&text, None, &kicad_core::board_model::Opts { y_up: false }) {+            Ok(m) => m,+            Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+        };+        let margin = args.get("marginMm").and_then(Value::as_f64).unwrap_or(2.0);+        match kicad_core::render::frame_parts(&model, &refs, margin) {+            Ok((px, py, z)) => {+                argv.push("--pivot".into());+                argv.push(format!("{px},{py},0"));+                zoom = zoom.or(Some(z));+                framed = json!({"refs": refs, "pivotCm": [px, py, 0], "zoom": zoom, "marginMm": margin});+            }+            Err(e) => return fail("part_not_found", e, "kicad_list_designators lists the board's parts."),+        }+    }+    if let Some(z) = zoom {         argv.push("--zoom".into());         argv.push(format!("{z}"));     }+    if let Some(r) = arg_str(args, "rotate") {+        argv.push("--rotate".into());+        argv.push(r.to_string());+    }     if args.get("perspective").and_then(Value::as_bool).unwrap_or(false) {         argv.push("--perspective".into());     }@@ -583,7 +615,7 @@ fn render_board(state: &mut State, args: &Value) -> Value {     json!({         "success": true,         "output": format!("Rendered the {side} side to {out_s}"),-        "data": {"output_path": out_s, "bytes": bytes, "side": side, "width": width, "height": height},+        "data": {"output_path": out_s, "bytes": bytes, "side": side, "width": width, "height": height, "framed": framed},         "path": path_s,         "cliElapsedMs": r.elapsed_ms,         "cliVersion": inst.cli_version,
rust/crates/kicad-bridge/src/verbs_board.rs+5−1
@@ -598,12 +598,16 @@ fn listed_edit(state: &mut State, args: &Value, which: Listed) -> Value {                 list["success"] = json!(true);                 list["mutated"] = json!(false);                 list["source"] = json!(if live { "live-editor" } else { "file" });+                // the revision an edit of this board takes as expectedRevision+                if let Ok(rev) = kicad_core::board_model::revision_of_text(&text) {+                    list["revision"] = json!(rev);+                }                 if !live_err.is_null() {                     list["live"] = live_err;                 }                 list["_hint"] = json!(match which {                     Listed::Graphics => "Edit one with uuid and change ({width, layer, points, start, end, mid, center, fill, delete}). Points are footprint-local mm, unrotated.",-                    Listed::Models => "Change them with ops: [{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}].",+                    Listed::Models => "Change them with ops: [{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}] and expectedRevision: revision.",                 });                 list             }
rust/crates/kicad-core/src/render.rs+47
@@ -78,6 +78,40 @@ pub fn export_step(run: Runner, pcb: &Path, out_dir: &Path, timeout: Duration) -     Ok(out) } +/// Where `kicad-cli pcb render` should look to frame some parts (wiki #105): its `--pivot` (cm, from the+/// board's centre, the Edge.Cuts box, y up; the same on either side, the renderer mirrors the bottom) and+/// the `--zoom` that fits the parts' pads, grown by `margin` mm, in the frame (zoom 1 fits the board).+/// `model` is the board model in KiCad's frame (y down). Measured on KiCad 10.0.7: J1 of the ESC board,+/// 21.5 mm left of centre, lands mid-frame at pivot -2.15,-0.2.+pub fn frame_parts(model: &Value, refs: &[String], margin: f64) -> Result<(f64, f64, f64), String> {+    let f = |v: &Value| v.as_f64().unwrap_or(0.0);+    let outline: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (f(&p[0]), f(&p[1]))).collect();+    if outline.is_empty() {+        return Err("the board has no outline on Edge.Cuts".into());+    }+    let bbox = |pts: &mut dyn Iterator<Item = (f64, f64, f64, f64)>| pts.fold(None, |b: Option<(f64, f64, f64, f64)>, (x0, y0, x1, y1)| Some(match b {+        None => (x0, y0, x1, y1),+        Some(o) => (o.0.min(x0), o.1.min(y0), o.2.max(x1), o.3.max(y1)),+    }));+    let (bx0, by0, bx1, by1) = bbox(&mut outline.iter().map(|&(x, y)| (x, y, x, y))).unwrap();+    let want = |r: &str| refs.iter().any(|w| w.eq_ignore_ascii_case(r));+    let pads: Vec<&Value> = model["pads"].as_array().into_iter().flatten().filter(|p| p["ref"].as_str().map_or(false, want)).collect();+    let missing: Vec<&String> = refs.iter().filter(|r| !pads.iter().any(|p| p["ref"].as_str().map_or(false, |x| x.eq_ignore_ascii_case(r)))).collect();+    if !missing.is_empty() {+        return Err(format!("not on the board (or no pads): {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));+    }+    let (px0, py0, px1, py1) = bbox(&mut pads.iter().map(|p| {+        let r = f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0;+        (f(&p["x"]) - r, f(&p["y"]) - r, f(&p["x"]) + r, f(&p["y"]) + r)+    })).unwrap();+    let (cx, cy) = ((bx0 + bx1) / 2.0, (by0 + by1) / 2.0);+    let (ox, oy) = ((px0 + px1) / 2.0 - cx, (py0 + py1) / 2.0 - cy);+    let (w, h) = (px1 - px0 + 2.0 * margin, py1 - py0 + 2.0 * margin);+    let zoom = ((bx1 - bx0) / w).min((by1 - by0) / h).clamp(1.0, 50.0);+    let r = |v: f64| (v * 1000.0).round() / 1000.0 + 0.0;+    Ok((r(ox / 10.0), r(-oy / 10.0), (zoom * 100.0).round() / 100.0))+}+ /// STEP export deadline (matches the kicad_export_step verb's budget). pub const STEP_TIMEOUT: Duration = Duration::from_secs(280); /// Per-side render deadline (Python: 180 s).@@ -194,6 +228,19 @@ pub fn export_molecule(installed: bool, args: &Value, run: Runner) -> Value {  #[cfg(test)] mod tests {++    #[test]+    fn parts_are_framed_from_the_board_centre() {+        let t = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb")).unwrap();+        let m = crate::board_model::build(&t, None, &crate::board_model::Opts { y_up: false }).unwrap();+        let (x, y, z) = frame_parts(&m, &["J1".into()], 2.0).unwrap();+        // J1's pads centre about 21.5 mm left of the board centre and a little below it: pivot in cm, y up+        assert!((x + 2.15).abs() < 0.15 && y.abs() < 0.3, "{x} {y}");+        assert!(z > 3.0 && z < 10.0, "{z}");+        let (_, _, zall) = frame_parts(&m, &["J1".into(), "Q1".into()], 2.0).unwrap();+        assert!(zall < z, "two parts far apart need a wider view");+        assert!(frame_parts(&m, &["NOPE9".into()], 2.0).unwrap_err().contains("NOPE9"));+    }     use super::*;     use std::cell::RefCell; 
rust/crates/kicad-core/src/windows_model.rs+35−3
@@ -508,7 +508,9 @@ pub fn norm_accel(text: &str) -> String { /// carries grid dots, axes and the part; an unrendered one is one flat colour. /// `rect` is (left, top, right, bottom) in window pixels (the wxGLCanvas), inset 4% so /// border pixels never count; without it a coarse guess of the window is sampled and-/// `region` says "approx".+/// `region` says "approx". A drawn canvas also has rows that vary: a blank one is flat rows, even+/// with a stray band across it (wiki #100: a white 3D viewer with one black strip had four colours,+/// all lit, and only 3% of its rows varied). pub fn canvas_uniformity(rgb: &[u8], width: u32, height: u32, rect: Option<(i32, i32, i32, i32)>) -> Value {     let (w, h) = (width as i64, height as i64);     if w <= 0 || h <= 0 || rgb.len() < (w * h * 3) as usize {@@ -534,19 +536,28 @@ pub fn canvas_uniformity(rgb: &[u8], width: u32, height: u32, rect: Option<(i32,     let mut colors: std::collections::HashSet<[u8; 3]> = std::collections::HashSet::new();     let mut samples = 0u64;     let mut lit_samples = 0u64;+    let (mut rows, mut varied_rows) = (0u64, 0u64);     let mut y = y0;     while y < y1 {         let mut x = x0;+        let first = { let i = ((y * w + x0) * 3) as usize; [rgb[i], rgb[i + 1], rgb[i + 2]] };+        let mut varied = false;         while x < x1 {             let i = ((y * w + x) * 3) as usize;-            colors.insert([rgb[i], rgb[i + 1], rgb[i + 2]]);+            let c = [rgb[i], rgb[i + 1], rgb[i + 2]];+            colors.insert(c);+            varied |= c != first;             samples += 1;             if rgb[i].max(rgb[i+1]).max(rgb[i+2]) > 24 { lit_samples += 1; }             x += step_x;         }+        rows += 1;+        varied_rows += varied as u64;         y += step_y;     }-    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3 && lit_samples * 100 >= samples * 3, "litSamples":lit_samples, "method":"canvas color heuristic, not geometry qualification", "samples": samples, "region": region})+    let varied_pct = if rows == 0 { 0.0 } else { (varied_rows * 1000 / rows) as f64 / 10.0 };+    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3 && lit_samples * 100 >= samples * 3 && varied_rows * 10 >= rows,+        "litSamples": lit_samples, "variedRowsPct": varied_pct, "method": "canvas color heuristic, not geometry qualification", "samples": samples, "region": region}) }  /// A filename-safe label for a screenshot (kicad_ui.handle_screenshot_all).@@ -725,6 +736,27 @@ mod tests {         assert_eq!(canvas_uniformity(&rgb,200,100,None)["rendered"],json!(false));     }     #[test]+    fn a_blank_canvas_with_a_stray_band_is_not_rendered() {+        // wiki #100: a white viewer with one black strip across a quarter of its width+        let (w, h) = (400u32, 240u32);+        let mut rgb = vec![255u8; (w * h * 3) as usize];+        for y in 40..46u32 {+            for x in 0..100u32 {+                let i = ((y * w + x) * 3) as usize;+                rgb[i..i + 3].copy_from_slice(&[20, 20, 20]);+            }+            for x in 100..116u32 {+                // an anti-aliased edge, wide enough for the sampling grid to see its shades+                let i = ((y * w + x) * 3) as usize;+                let g = 60 + (x - 100) as u8 * 12;+                rgb[i..i + 3].copy_from_slice(&[g, g, g]);+            }+        }+        let v = canvas_uniformity(&rgb, w, h, Some((0, 0, w as i32, h as i32)));+        assert!(v["distinctColors"].as_u64().unwrap() > 3, "{v}");+        assert_eq!(v["rendered"], json!(false), "{v}");+    }+    #[test]     fn canvas_probe_flat_versus_drawn() {         let (w, h) = (200u32, 100u32);         let flat = vec![40u8; (w * h * 3) as usize];
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.4.0"+version = "0.4.1" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+2−2
@@ -17,7 +17,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |-| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `bodiesUsed: false` when no bodies were given |+| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -88,7 +88,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.4.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.4.1"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.7.0-- Standard commit: e7b8a3e85d4f36c801139875af9f496103ec0919-- Crate version: 0.4.0+- Standard version: 0.7.1+- Standard commit: 7e8005c53c56c44075c081c1b12c3fdce28810e6+- Crate version: 0.4.1 - Copied: 2026-10-28, by kicad-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/placement.rs+54−1
@@ -116,7 +116,36 @@ pub fn placement_check(input: &Value) -> Value {     let holes: Vec<(f64, f64, f64)> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| r.is_empty()).unwrap_or(true))         .map(|p| (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["drill"])) / 2.0)).collect();     let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);-    let mut out = review(&boxes, &pads, &ring, &holes, num("marginMm", 0.1), num("edgeMm", 0.5));+    let margin = num("marginMm", 0.1);+    let mut out = review(&boxes, &pads, &ring, &holes, margin, num("edgeMm", 0.5));+    // a part under a raised one (an Adom molecule on pins, a connector's overhang) overlaps it in plan view only: when+    // their pads stay clear and no body of one meets a body of the other in 3D, it is a stack, listed apart, not an+    // overlap (fusion-lane #19). A part with no body cannot be shown stacked, so its overlaps stand.+    if let Some(bs) = &given {+        // each pad's own box, turned with it: a molecule's pins span the part under it, so the union of its pads would too+        let pad_rects = |r: &str| -> Vec<Rect> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| {+            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+            let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);+            (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+        }).collect() };+        let meets = |a: &str, b: &str| {+            let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());+            if ba.is_empty() || bb.is_empty() { return true; }+            ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))+        };+        let (mut real, mut stacked) = (Vec::new(), Vec::new());+        for o in out["courtyardOverlaps"].as_array().cloned().unwrap_or_default() {+            let (a, b) = (o["parts"][0].as_str().unwrap_or(""), o["parts"][1].as_str().unwrap_or(""));+            let (pa, pb) = (pad_rects(a), pad_rects(b));+            let pads_meet = pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin))));+            if pads_meet || meets(a, b) { real.push(o) } else { stacked.push(o) }+        }+        if !stacked.is_empty() {+            out["ok"] = json!(real.is_empty() && out["offOrOverEdge"].as_array().map(|a| a.is_empty()).unwrap_or(true) && out["onMountingHoles"].as_array().map(|a| a.is_empty()).unwrap_or(true));+        }+        out["courtyardOverlaps"] = json!(real);+        out["stacked"] = json!(stacked);+    }     out["success"] = json!(true);     out["bodiesUsed"] = json!(given.is_some());     out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies.");@@ -148,4 +177,28 @@ mod tests {         assert_eq!(r["nearEdge"][0]["ref"], "U1");         assert_eq!(r["ok"], json!(false));     }++    #[test]+    fn a_part_under_a_raised_one_is_stacked_not_overlapping() {+        let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0.0});+        // M1: a molecule on two pins at x 0 and 10, its board raised 3 to 4 mm; C1 sits under it, between the pins+        let st = json!({"outline": [[-5, -5], [15, -5], [15, 5], [-5, 5]],+            "outlineEdges": [{"kind": "line", "start": [-5.0, -5.0]}, {"kind": "line", "start": [15.0, -5.0]}, {"kind": "line", "start": [15.0, 5.0]}, {"kind": "line", "start": [-5.0, 5.0]}],+            "components": [{"ref": "M1", "side": "top"}, {"ref": "C1", "side": "top"}],+            "pads": [pad("M1", 0.0, 0.0), pad("M1", 10.0, 0.0), pad("C1", 5.0, 0.0), pad("C1", 6.0, 0.0)]});+        let body = |r: &str, x0: f64, x1: f64, z0: f64, z1: f64| json!({"ref": r, "side": "top", "x0": x0, "y0": -2.0, "x1": x1, "y1": 2.0, "z0": z0, "z1": z1});+        let stacked = placement_check(&json!({"board": st, "bodies": {"bodies": [body("M1", -1.0, 11.0, 3.0, 4.0), body("C1", 4.5, 6.5, 0.0, 1.0)]}, "args": {}}));+        assert_eq!(stacked["courtyardOverlaps"].as_array().unwrap().len(), 0, "{stacked}");+        assert_eq!(stacked["stacked"][0]["parts"], json!(["C1", "M1"]));+        assert_eq!(stacked["ok"], json!(true));+        // the same plan, but C1 tall enough to reach the raised board: a real overlap+        let real = placement_check(&json!({"board": st, "bodies": {"bodies": [body("M1", -1.0, 11.0, 3.0, 4.0), body("C1", 4.5, 6.5, 0.0, 3.5)]}, "args": {}}));+        assert_eq!(real["courtyardOverlaps"].as_array().unwrap().len(), 1);+        assert_eq!(real["ok"], json!(false));+        // no bodies: pads only, no stack possible to show, nothing hidden+        let none = placement_check(&json!({"board": st, "args": {}}));+        assert_eq!(none["bodiesUsed"], json!(false));+        assert!(none.get("stacked").is_none());+    } }+
skills/kicad-bridge-dev/SKILL.md+4−1
@@ -657,7 +657,10 @@ from host-app verdicts. **A window existing is not a window having painted.** Title verification returns true well before KiCad draws anything, so evidence captured at that moment can be structurally perfect and visually empty. `evidence.canvasRendered` samples the-real `wxGLCanvas` child rect; the capture polls for paint before returning.+real `wxGLCanvas` child rect; the capture polls for paint before returning. A canvas+counts as drawn only with more than 3 colours, 3% of samples lit, AND 10% of sampled+rows that vary (wiki #100: a white 3D viewer with one black strip passed the first two;+real views measure 33 to 100% varied rows).  ## 🔌 Plugin payload upgrades reach a RUNNING KiCad only on restart 
skills/kicad-bridge-test/run_verb_tests.py+20−2
@@ -1260,7 +1260,19 @@ def main():         r = K("get_selection", {"filePath": L2}); rec("get_selection", r, ok(r) and "U5" in json.dumps(r))         ab(a.target, "kicad_select_clear", {"filePath": L2}, timeout=60)         r = K("footprint_graphics", {"filePath": L2, "ref": "U5"}); rec("footprint_graphics", r, ok(r))-        r = K("footprint_models", {"filePath": L2, "ref": "U5"}); rec("footprint_models", r, ok(r))+        # a model binding changed, read back and put back (wiki #101), on the offline copy (no editor holds it):+        # KiCad 10 answers a live footprint replace empty (live_create_unsupported), so the closed file is the path+        r = K("footprint_models", {"filePath": L2, "ref": "U5"}); live_read = ok(r)+        OFFC = w["offline"]+        r = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file"})+        m0 = ((r.get("models") or [{}])[0]) if ok(r) else {}+        off = list(m0.get("offset") or [0, 0, 0])+        moved = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file", "expectedRevision": r.get("revision"), "ops": [{"action": "replace", "index": 0, "offset": [off[0], off[1], round(off[2] + 0.2, 4)]}]}) if m0 else {}+        back = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file"}) if ok(moved) else {}+        z = (((back.get("models") or [{}])[0]).get("offset") or [0, 0, 0])[2] if back else None+        undo = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file", "expectedRevision": back.get("revision"), "ops": [{"action": "replace", "index": 0, "offset": off}]}) if ok(moved) else {}+        rec("footprint_models", {"read": r, "moved": moved, "back": back, "undo": undo}, live_read and ok(r) and bool(m0) and ok(moved) and z is not None and abs(z - off[2] - 0.2) < 1e-6 and ok(undo),+            "U5's model offset was not changed in the file, read back and restored")         # creating items live needs KiCad's ParseAndCreateItemsFromString, which KiCad 10.0.4 answers empty: one live         # add must either land or refuse with live_create_unsupported and nothing changed; the edit sequence itself         # runs on the closed copy below (the file path), which works on every KiCad@@ -1510,8 +1522,14 @@ def main():         # 1.0.72: height_check, placement_check, thermal_vias and via_fence answered by the standard's engine, which names itself         eng = lambda r: str(r.get("engine") or "").startswith("adom-eda-checks ")         r = C("height_check", dict(fb, maxTopMm=20), 240); own("height_check", r, ok(r) and r.get("heights") and eng(r), "no heights, or not from the engine")+        # 1.0.73: a part framed in KiCad's own render (wiki #105): the pivot is J1's offset from the board centre, in cm+        r = ab(a.target, "kicad_render_board", {"filePath": CK, "ref": "J1", "rotate": "-45,0,45", "width": 800, "height": 600, "outputDir": w["out"] + "/framed"}, timeout=300)+        fr = (r.get("data") or {}).get("framed") or {}+        piv = fr.get("pivotCm") or [0, 0]+        record("render_board_framed", r, ok(r) and abs(piv[0] + 2.15) < 0.15 and (fr.get("zoom") or 0) > 3 and (r.get("data") or {}).get("bytes", 0) > 10000,+               "J1 was not framed (pivot, zoom) or no image was written")         r = C("placement_check", fb, 240)-        own("placement_check", r, ok(r) and isinstance(r.get("courtyardOverlaps"), list) and r.get("bodiesUsed") is True and (r.get("parts") or 0) > 10 and eng(r),+        own("placement_check", r, ok(r) and isinstance(r.get("courtyardOverlaps"), list) and isinstance(r.get("stacked"), list) and r.get("bodiesUsed") is True and (r.get("parts") or 0) > 10 and eng(r),             "no placement review with the bodies, or not from the engine")         r = C("pin_pad_check", {"projectPath": w["pro"]}, 240); own("pin_pad_check", r, ok(r) and "matching" in r and isinstance(r.get("mismatches"), list))         r = C("auto_classes", fb); own("auto_classes", r, ok(r) and ((r.get("proposal") or {}).get("classes") or {}).get("GND"))