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.
← All Pull Requests
kicad-lane: 1.0.73, framed renders, canvas check, engine 0.4.1 #34
Merged opened by Colby Knox 2026-10-28
kicad-lane: 1.0.73.
- kicad_render_board ref / refs / marginMm / rotate (wiki #105). kicad_core::render::frame_parts frames those parts in KiCad's own renderer:
- the pivot is the parts' pad centre minus the board centre (Edge.Cuts box), in cm with y up, the same on either side because the renderer mirrors the bottom;
- the zoom fits the parts' pads plus marginMm (zoom 1 fits the board); data.framed reports both.
- Measured on KiCad 10.0.7 with the ESC board: J1 is centred top, bottom and iso, and J1 with Q1 frames both. There is a unit test on the fixture, and a battery check.
- canvasRendered (wiki #100). windows_model::canvas_uniformity also needs 10% of its sampled rows to vary, and reports variedRowsPct.
- John's capture (a white 3D viewer with one black strip) had 4 colours, 99.6% lit and 3.2% varied rows, and was reported rendered. It now is not.
- Real views on the laptop measured 33% (a small 3D chip) to 100% (a symbol with grid dots). There is a unit test for the band case.
- kicad_footprint_models / kicad_footprint_graphics reads return
revision, the expectedRevision their edits take; a caller had no way to get it from the read. - Battery: a model offset change on U5, read back and restored, runs on the closed offline copy (wiki #101). KiCad 10 answers the live footprint replace empty (live_create_unsupported).
- Engine: adom-eda-checks 0.4.1 (standard 0.7.1, 7e8005c5), vendored unchanged. placement_check lists a part under a raised one as
stacked; the battery checks the key.
Full battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 302/0/1.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-eda-checks"-version = "0.4.0"+version = "0.4.1" dependencies = [ "roxmltree", "serde_json",⋯ 302 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.72"+version = "1.0.73" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.72"+version = "1.0.73" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.72"+version = "1.0.73" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.72"+version = "1.0.73" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,361 +1,362 @@⋯ 146 unchanged lines ⋯ 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."], },⋯ 203 unchanged lines ⋯ assert_eq!(doc["prewarm"]["verb"], "kicad_prewarm"); } }+@@ -1,624 +1,657 @@⋯ 563 unchanged lines ⋯ 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()); }⋯ 9 unchanged lines ⋯ 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,⋯ 32 unchanged lines ⋯ _ => catalog.into(), } }+@@ -1,855 +1,860 @@⋯ 597 unchanged lines ⋯ 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 }⋯ 243 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -1,344 +1,392 @@⋯ 77 unchanged lines ⋯ 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).⋯ 110 unchanged lines ⋯ #[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; ⋯ 142 unchanged lines ⋯ assert_eq!(e, "silk-top (kicad-cli pcb render rc=0)"); } }+@@ -1,751 +1,785 @@⋯ 507 unchanged lines ⋯ /// 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 {⋯ 19 unchanged lines ⋯ 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).⋯ 172 unchanged lines ⋯ 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];⋯ 18 unchanged lines ⋯ assert_eq!(safe_label("", "dialog"), "dialog"); } }++@@ -1,25 +1,26 @@ [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"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,102 +1,103 @@⋯ 16 unchanged lines ⋯ | `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 | ⋯ 64 unchanged lines ⋯ 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 ⋯ 5 unchanged lines ⋯ ``` Fixtures live on each bridge's own page, not here.+@@ -1,13 +1,14 @@⋯ 2 unchanged lines ⋯ 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`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests. Never edit the copy: fixes go to the standard (an issue titled `kicad-lane: ...`).+@@ -1,151 +1,205 @@⋯ 115 unchanged lines ⋯ 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.");⋯ 25 unchanged lines ⋯ 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());+ } }++@@ -1,1052 +1,1056 @@⋯ 656 unchanged lines ⋯ **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 ⋯ 386 unchanged lines ⋯ calls (`kicad_apply_zone_plan`, `kicad_custom_rules`). The checks the engine does not have yet are the bridge's own (`kicad_core::board_checks`, `verbs_checks_native.rs`): when the engine gains one, switch the verb to it and keep the reply's shape.+@@ -1,1662 +1,1681 @@⋯ 1259 unchanged lines ⋯ 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⋯ 243 unchanged lines ⋯ # 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"))⋯ 142 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.