skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
← Commit history
standard 0.7.0, engine 0.4.0: height_check and placement_check move into the engine from the Altium bridge's bodies.rs and placement.rs, reading the bodies companion; both match Altium 0.2.76 on lane and sentinel (5 conformance tests now)
11 files changed
+369−12
README.md+4−2@@ -42,8 +42,8 @@ A verb one bridge gains should become every bridge's to-do: that is what this pa and its companions go in as plain JSON, findings and plans come out, and nothing calls a bridge or a tool. A bridge links the crate (vendored at a tagged standard version, since Cargo cannot fetch from the wiki) or runs the command, and answers each check under its own prefix. It now answers `copper_balance`, `return_path`, `fab_check`, `impedance`,-`current_capacity`, `thermal_vias` and `via_fence`, moved over from the Altium bridge (the first five give the same-answers as the Altium bridge on its test boards). It also+`current_capacity`, `height_check`, `placement_check`, `thermal_vias` and `via_fence`, moved over from the Altium+bridge (all but the via plans give the same answers as the Altium bridge on its test boards). It also carries board-file writers (`export_gencad`, `export_dxf`, `import_svg`) for a bridge whose tool has no native exporter. Its conformance tests read a bridge's saved replies from `ADOM_EDA_FIXTURES`; fixtures live on each bridge's own page (Altium's: [adom/altium-bridge](https://wiki.adom.inc/adom/altium-bridge) `fixtures/`).@@ -157,6 +157,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.7.0: the engine (0.4.0) adds `height_check` and `placement_check` from the Altium bridge, reading the 3D bodies+companion; both match Altium's answers on its test boards, and Fusion gets them for the first time. Version 0.6.0: the engine (0.3.0) adds `thermal_vias` and `via_fence` from the Altium bridge, as plans in `place_vias` arguments, so KiCad and Fusion can swap their own copies for one; every engine reply names its version (`engine`). Version 0.5.1: the engine (0.2.1) writes one GenCAD shape per footprint pattern and pad geometry: two different
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.3.0"+version = "0.4.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.3.0"+version = "0.4.0" 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"
engine/README.md+6−3@@ -16,14 +16,16 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `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 | | `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 | The via plans size their vias from `args.size`/`drill`, else the board's via rule (`rules`: `{kind: "via", sizeMm, drillMm}`, or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`. -The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning,-designators, panels, the board report, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout+The other checks-tier verbs (assembly, pair routing, length tuning,+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout and plan_route) come over a module at a time. ## Writers@@ -51,6 +53,7 @@ Every check takes one JSON object: | `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) | | `stack` | the stack-up companion, `<prefix>_layer_stack` | | `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |+| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody` | | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |@@ -85,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.3.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.4.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/bodies.rsadded+187@@ -0,0 +1,187 @@+//! The board in 3D from its parts' bodies (the bodies companion): how tall each part stands on its side, which ones break+//! an enclosure's height limit (board-wide, or under a lid feature in a region), and which bodies collide or come closer+//! than a gap. Bodies are compared as boxes (each body's footprint rectangle and its standoff-to-top range); a part made+//! of several bodies is several boxes, so L-shapes stay honest; a bridge that gives one box per part (Fusion) is the+//! union. The tool's own component clearance DRC checks the real shapes. From the Altium bridge's bodies.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }++/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+#[derive(Debug, Clone)]+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {+ rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| BodyBox {+ part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"),+ x0: f(&r["x0"]).min(f(&r["x1"])), y0: f(&r["y0"]).min(f(&r["y1"])), x1: f(&r["x0"]).max(f(&r["x1"])), y1: f(&r["y0"]).max(f(&r["y1"])),+ z0: f(&r["z0"]), z1: f(&r["z1"]),+ }).collect()+}++/// The input's bodies: the companion's `bodies`, or the companion given as a bare list.+pub fn input_boxes(input: &Value) -> Option<Vec<BodyBox>> {+ let b = crate::input::part(input, "bodies")?;+ let rows = b.get("bodies").or(Some(&b)).and_then(|x| x.as_array()).cloned()?;+ Some(boxes(&rows))+}++/// Parts on the board with no body: the companion's `noBody`, and every board part the boxes leave out.+fn no_body(input: &Value, bs: &[BodyBox]) -> Vec<Value> {+ let with: std::collections::BTreeSet<&str> = bs.iter().map(|b| b.part.as_str()).collect();+ let mut out: Vec<String> = crate::input::part(input, "bodies").and_then(|b| b["noBody"].as_array().cloned()).unwrap_or_default()+ .iter().filter_map(|v| v.as_str().map(String::from)).collect();+ if let Ok(st) = crate::input::board(input) {+ for r in st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)) {+ if !with.contains(r.as_str()) && !out.contains(&r) { out.push(r); }+ }+ }+ out.into_iter().map(Value::from).collect()+}++/// Each part's height above its side and its footprint in mm, tallest first.+pub fn heights(bs: &[BodyBox]) -> Vec<Value> {+ let mut m: BTreeMap<&str, (bool, f64, f64, f64, f64, f64)> = BTreeMap::new();+ for b in bs {+ let e = m.entry(&b.part).or_insert((b.top, 0.0, f64::MAX, f64::MAX, f64::MIN, f64::MIN));+ e.1 = e.1.max(b.z1);+ e.2 = e.2.min(b.x0); e.3 = e.3.min(b.y0); e.4 = e.4.max(b.x1); e.5 = e.5.max(b.y1);+ }+ let mut v: Vec<Value> = m.into_iter().map(|(p, (top, h, x0, y0, x1, y1))| json!({"ref": p, "side": if top { "top" } else { "bottom" }, "heightMm": r3(h), "rect": [r3(x0), r3(y0), r3(x1), r3(y1)]})).collect();+ v.sort_by(|a, b| b["heightMm"].as_f64().partial_cmp(&a["heightMm"].as_f64()).unwrap());+ v+}++/// Body pairs of different parts on the same side that overlap in 3D (`collisions`), or whose boxes come within+/// `gap` mm of each other in x and y where their heights overlap (`tooClose`). Pure.+pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) {+ let mut idx: Vec<usize> = (0..bs.len()).collect();+ idx.sort_by(|a, b| bs[*a].x0.partial_cmp(&bs[*b].x0).unwrap());+ let mut seen: BTreeMap<(String, String), (bool, f64, Value)> = BTreeMap::new();+ for (k, &i) in idx.iter().enumerate() {+ let a = &bs[i];+ for &j in &idx[k + 1..] {+ let b = &bs[j];+ if b.x0 > a.x1 + gap { break; }+ if a.part == b.part || a.top != b.top { continue; }+ let dz = a.z1.min(b.z1) - a.z0.max(b.z0);+ if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)+ let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+ let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+ let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+ if !hit && d >= gap { continue; }+ let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) };+ let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);+ // per pair keep the worst: a collision over a near miss, then the deepest or the closest+ let worse = match seen.get(&key) { None => true, Some((h, v, _)) => (hit && !h) || (hit == *h && if hit { d > *v } else { d < *v }) };+ if worse { seen.insert(key, (hit, d, at)); }+ }+ }+ let (mut col, mut near) = (Vec::new(), Vec::new());+ for ((p, q), (hit, d, at)) in seen {+ if hit { col.push(json!({"parts": [p, q], "overlapMm": r3(d), "at": at})); } else { near.push(json!({"parts": [p, q], "gapMm": r3(d), "at": at})); }+ }+ (col, near)+}++/// height_check: input {bodies, board?, args: {maxTopMm?, maxBottomMm?, regions?: [{rect, maxMm, side?, name?}], minGapMm?, limit?}}.+/// `board` names the parts with no body (they have no height, and the reply says the check is not complete).+pub fn height_check(input: &Value) -> Value {+ let args = &input["args"];+ let Some(bs) = input_boxes(input) else { return crate::input::refuse("no_bodies", "the input has no bodies: pass the bodies companion (<prefix>_bodies) as bodies") };+ let hs = heights(&bs);+ let num = |k: &str| args.get(k).and_then(|v| v.as_f64());+ let mut over = Vec::new();+ for (side, lim) in [("top", num("maxTopMm")), ("bottom", num("maxBottomMm"))] {+ let Some(lim) = lim else { continue };+ for h in hs.iter().filter(|h| h["side"] == side) {+ let mm = h["heightMm"].as_f64().unwrap_or(0.0);+ if mm > lim { over.push(json!({"ref": h["ref"], "side": side, "heightMm": mm, "limitMm": lim, "overByMm": r3(mm - lim)})); }+ }+ }+ // a region is a lid feature over part of the board (a boss, a rib, a display): parts under it must stay lower+ let mut region_hits = Vec::new();+ for (i, rg) in args.get("regions").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+ let rc: Vec<f64> = rg["rect"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();+ let Some(max) = rg["maxMm"].as_f64() else { return json!({"success": false, "errorCode": "bad_args", "error": "each region is {rect:[x1,y1,x2,y2], maxMm, side?, name?}"}) };+ if rc.len() != 4 { return json!({"success": false, "errorCode": "bad_args", "error": "region.rect is [x1, y1, x2, y2] in mm"}); }+ let (rx0, ry0, rx1, ry1) = (rc[0].min(rc[2]), rc[1].min(rc[3]), rc[0].max(rc[2]), rc[1].max(rc[3]));+ let top = rg["side"].as_str() != Some("bottom");+ let name = rg["name"].as_str().map(|s| s.to_string()).unwrap_or(format!("region {}", i + 1));+ let mut worst: BTreeMap<&str, f64> = BTreeMap::new();+ for b in bs.iter().filter(|b| b.top == top && b.x1 > rx0 && b.x0 < rx1 && b.y1 > ry0 && b.y0 < ry1 && b.z1 > max) {+ let e = worst.entry(&b.part).or_insert(0.0);+ *e = e.max(b.z1);+ }+ for (p, h) in worst { region_hits.push(json!({"region": name, "ref": p, "heightMm": r3(h), "maxMm": max, "overByMm": r3(h - max)})); }+ }+ let gap = num("minGapMm").unwrap_or(0.0);+ let (collisions, too_close) = clashes(&bs, gap);+ // parts with no body have no height: say so, a height check that skips them is not a pass+ let no_body = no_body(input, &bs);+ let tallest = |side: &str| hs.iter().find(|h| h["side"] == side).cloned().unwrap_or(Value::Null);+ let ok = over.is_empty() && region_hits.is_empty() && collisions.is_empty() && too_close.is_empty();+ json!({"success": true, "ok": ok, "complete": no_body.is_empty(), "bodies": bs.len(), "parts": hs.len(), "tallest": {"top": tallest("top"), "bottom": tallest("bottom")},+ "overLimit": over, "regionHits": region_hits, "collisions": collisions, "tooClose": too_close, "minGapMm": gap,+ "noBody": no_body, "heights": hs.iter().take(args.get("limit").and_then(|v| v.as_u64()).unwrap_or(25) as usize).collect::<Vec<_>>(),+ "_hint": "Heights are each part's tallest body above the surface it sits on (bottom-side parts below the bottom). collisions and tooClose compare body boxes; the tool's component clearance DRC (run_drc) checks the real shapes. ok covers the parts that have bodies; complete is false while noBody lists parts with no 3D body and so no height: give them one (add_body) before trusting the check."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {+ BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+ }++ #[test]+ fn the_companion_becomes_boxes() {+ let b = boxes(&[json!({"ref": "J1", "side": "bottom", "x0": 10.0, "y0": 5.0, "x1": 0.0, "y1": 0.0, "z0": 0.0, "z1": 4.1}), json!({"ref": "", "x0": 1})]);+ assert_eq!(b.len(), 1);+ assert!(!b[0].top && (b[0].z1 - 4.1).abs() < 1e-9 && (b[0].x1 - 10.0).abs() < 1e-9 && b[0].x0 == 0.0);+ }++ #[test]+ fn heights_take_each_parts_tallest_body() {+ let bs = vec![bx("J1", true, 0.0, 0.0, 10.0, 5.0, 0.0, 3.2), bx("J1", true, 0.0, 0.0, 10.0, 5.0, 4.0, 4.1), bx("R1", true, 20.0, 0.0, 21.0, 0.5, 0.0, 0.4)];+ let h = heights(&bs);+ assert_eq!(h[0]["ref"], "J1");+ assert_eq!(h[0]["heightMm"], 4.1);+ assert_eq!(h[1]["rect"], json!([20.0, 0.0, 21.0, 0.5]));+ }++ #[test]+ fn clashes_find_overlaps_near_misses_and_ignore_stacked_and_opposite_sides() {+ let bs = vec![+ bx("U1", true, 0.0, 0.0, 5.0, 5.0, 0.0, 1.0),+ bx("C1", true, 4.0, 1.0, 6.0, 2.0, 0.0, 0.5), // overlaps U1 by 1 mm in x+ bx("C2", true, 5.2, 3.0, 6.0, 4.0, 0.0, 0.5), // 0.2 mm from U1+ bx("C3", false, 1.0, 1.0, 2.0, 2.0, 0.0, 0.5), // under U1, other side+ bx("D1", true, 1.0, 1.0, 2.0, 2.0, 1.5, 2.0), // above U1's top+ ];+ let (col, near) = clashes(&bs, 0.3);+ assert_eq!(col.len(), 1);+ assert_eq!(col[0]["parts"], json!(["C1", "U1"]));+ assert_eq!(col[0]["overlapMm"], 1.0);+ assert_eq!(near.len(), 1);+ assert_eq!(near[0]["parts"], json!(["C2", "U1"]));+ assert_eq!(near[0]["gapMm"], 0.2);+ assert!(clashes(&bs, 0.0).1.is_empty());+ }++ #[test]+ fn height_check_reads_the_companion_and_names_parts_without_bodies() {+ let input = json!({"board": {"components": [{"ref": "U1"}, {"ref": "R7"}]},+ "bodies": {"bodies": [{"ref": "U1", "side": "top", "x0": 0.0, "y0": 0.0, "x1": 5.0, "y1": 5.0, "z0": 0.0, "z1": 2.0}], "noBody": []},+ "args": {"maxTopMm": 1.5}});+ let r = height_check(&input);+ assert_eq!(r["overLimit"][0]["ref"], "U1");+ assert_eq!(r["noBody"], json!(["R7"]));+ assert_eq!(r["complete"], json!(false));+ assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies");+ }+}
engine/src/lib.rs+5−1@@ -7,6 +7,7 @@ //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value; +pub mod bodies; pub mod coppermap; pub mod current; pub mod fab;@@ -14,6 +15,7 @@ pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod placement; pub mod writers; /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand@@ -21,7 +23,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -36,7 +38,9 @@ fn answer(check: &str, input: &Value) -> Value { "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "fab_check" => fab::fab_check(input),+ "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "placement_check" => placement::placement_check(input), "return_path" => coppermap::return_path(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input),
engine/src/placement.rsadded+151@@ -0,0 +1,151 @@+//! A placement review before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same+//! side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a+//! mounting hole. From the Altium bridge's placement.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);++fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }++/// Each part's box (its pads, and its 3D body's box when it has one) and side. Pure.+pub fn part_boxes(st: &Value, bodies: &BTreeMap<String, Rect>) -> BTreeMap<String, (bool, Rect)> {+ let mut m: BTreeMap<String, (bool, Rect)> = BTreeMap::new();+ let side: BTreeMap<String, bool> = st["components"].as_array().cloned().unwrap_or_default().iter()+ .filter_map(|c| Some((c["ref"].as_str()?.to_string(), c["side"] == "bottom"))).collect();+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ 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);+ let b = (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy);+ let e = m.entry(r.to_string()).or_insert((side.get(r).copied().unwrap_or(false), b));+ e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3));+ }+ for (r, b) in bodies {+ if let Some(e) = m.get_mut(r) { e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3)); }+ }+ m+}++/// The review. `ring` is the outline (mm); holes are (x, y, radius) of the board's own drilled, part-less pads. Pure.+pub fn review(boxes: &BTreeMap<String, (bool, Rect)>, pads: &[Value], ring: &[(f64, f64)], holes: &[(f64, f64, f64)], margin: f64, edge: f64) -> Value {+ let (bx0, bx1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (by0, by1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let inside = |x: f64, y: f64| {+ let mut c = false;+ for k in 0..ring.len() {+ let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+ if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ }+ c+ };+ let dist = |x: f64, y: f64| (0..ring.len()).map(|k| {+ let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ ((a.0 + t * dx - x).powi(2) + (a.1 + t * dy - y).powi(2)).sqrt()+ }).fold(f64::MAX, f64::min);+ let (mut overlaps, mut off, mut near, mut on_holes) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ let list: Vec<(&String, &(bool, Rect))> = boxes.iter().collect();+ for i in 0..list.len() {+ for j in i + 1..list.len() {+ let ((ra, (sa, a)), (rb, (sb, b))) = (list[i], list[j]);+ if sa == sb && hit(grow(*a, margin), grow(*b, margin)) {+ let ov = (a.2.min(b.2) - a.0.max(b.0)).min(a.3.min(b.3) - a.1.max(b.1));+ overlaps.push(json!({"parts": [ra, rb], "side": if *sa { "bottom" } else { "top" }, "overlapMm": r3(ov + 2.0 * margin)}));+ }+ }+ }+ // on or off the board by its pads' centres: a box's corners fall outside a round board's edge while the part sits+ // well inside it (the Bluetooth Sentinel's J1 read as off the board)+ let mut pad_in: BTreeMap<&str, (usize, usize)> = BTreeMap::new();+ for p in pads {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ let e = pad_in.entry(r).or_insert((0, 0));+ e.0 += 1;+ if inside(f(&p["x"]), f(&p["y"])) { e.1 += 1; }+ }+ let _ = (bx0, bx1, by0, by1);+ for (r, (_, b)) in boxes {+ let (n, n_in) = pad_in.get(r.as_str()).copied().unwrap_or((0, 0));+ if n > 0 && n_in == 0 { off.push(json!({"ref": r, "where": "off the board"})); continue; }+ if n_in < n { off.push(json!({"ref": r, "where": "over the board edge"})); continue; }+ for (x, y, hr) in holes { if hit(*b, (x - hr, y - hr, x + hr, y + hr)) { on_holes.push(json!({"ref": r, "hole": [r3(*x), r3(*y)]})); } }+ }+ // pads near the edge: the closest pad of each part+ let mut closest: BTreeMap<String, f64> = BTreeMap::new();+ for p in pads {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ // the pad's four corners, turned with it: inside the outline their distance counts, outside it is negative+ let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+ let (hx, hy) = (f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+ let d = [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].iter().map(|(u, v)| {+ let (x, y) = (f(&p["x"]) + u * c - v * s, f(&p["y"]) + u * s + v * c);+ if inside(x, y) { dist(x, y) } else { -dist(x, y) }+ }).fold(f64::MAX, f64::min);+ let e = closest.entry(r.to_string()).or_insert(f64::MAX);+ if d < *e { *e = d; }+ }+ for (r, d) in &closest {+ if *d < edge && !off.iter().any(|o| o["ref"] == json!(r)) { near.push(json!({"ref": r, "padToEdgeMm": r3(*d)})); }+ }+ let ok = overlaps.is_empty() && off.is_empty() && on_holes.is_empty();+ json!({"parts": boxes.len(), "ok": ok, "courtyardOverlaps": overlaps, "offOrOverEdge": off, "onMountingHoles": on_holes, "nearEdge": near, "marginMm": margin, "edgeMm": edge})+}++/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads+/// only, and the reply says so (`bodiesUsed: false`).+pub fn placement_check(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let ring = crate::coppermap::outline_ring(&st);+ if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); }+ // the parts' 3D bodies (silk_state's 'body' box takes in the designator text and made every neighbour overlap)+ let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+ let given = crate::bodies::input_boxes(input);+ for b in given.clone().unwrap_or_default() {+ let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+ *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+ }+ let boxes = part_boxes(&st, &bodies);+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ 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));+ 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.");+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn placement_review_finds_each_problem() {+ let ring = vec![(0.0, 0.0), (40.0, 0.0), (40.0, 20.0), (0.0, 20.0)];+ let mut boxes = BTreeMap::new();+ boxes.insert("U1".to_string(), (false, (5.0, 5.0, 10.0, 10.0)));+ boxes.insert("C1".to_string(), (false, (10.2, 5.0, 11.0, 6.0))); // 0.2 mm from U1: inside 2 x 0.25+ boxes.insert("C2".to_string(), (true, (10.2, 5.0, 11.0, 6.0))); // the same spot on the bottom: fine+ boxes.insert("J1".to_string(), (false, (38.0, 8.0, 42.0, 12.0))); // over the edge+ boxes.insert("R9".to_string(), (false, (50.0, 5.0, 51.0, 6.0))); // off the board+ boxes.insert("R2".to_string(), (false, (1.5, 15.5, 2.5, 16.5))); // on the hole at (2, 16)+ let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6});+ let pads = vec![pad("C1", 10.6, 5.5), pad("U1", 5.2, 0.6), pad("J1", 39.0, 10.0), pad("J1", 41.0, 10.0), pad("R9", 50.5, 5.5)];+ let r = review(&boxes, &pads, &ring, &[(2.0, 16.0, 1.6)], 0.25, 0.5);+ assert_eq!(r["courtyardOverlaps"].as_array().unwrap().len(), 1);+ assert_eq!(r["courtyardOverlaps"][0]["parts"], json!(["C1", "U1"]));+ let off: Vec<String> = r["offOrOverEdge"].as_array().unwrap().iter().map(|o| format!("{} {}", o["ref"].as_str().unwrap(), o["where"].as_str().unwrap())).collect();+ assert!(off.contains(&"J1 over the board edge".to_string()) && off.contains(&"R9 off the board".to_string()));+ assert_eq!(r["onMountingHoles"][0]["ref"], "R2");+ assert_eq!(r["nearEdge"][0]["ref"], "U1");+ assert_eq!(r["ok"], json!(false));+ }+}
engine/tests/fixtures.rs+11−1@@ -16,7 +16,7 @@ fn load(p: PathBuf) -> Option<Value> { Some(serde_json::from_str(&std::fs::read_ fn bundle(d: &PathBuf, board: &str) -> Option<Value> { Some(json!({"board": load(d.join(format!("{board}.routing_state.json")))?, "stack": load(d.join(format!("{board}.layer_stack.json")))?, "pours": load(d.join(format!("{board}.pour_state.json")))?, "rules": load(d.join(format!("{board}.read_rules.json"))),- "silk": load(d.join(format!("{board}.silk_state.json"))), "args": {}}))+ "silk": load(d.join(format!("{board}.silk_state.json"))), "bodies": load(d.join(format!("{board}.bodies.json"))), "args": {}})) } fn same(check: &str, keys: &[&str]) {@@ -54,3 +54,13 @@ fn fab_check_measures_what_altium_measures() { "measured.holeToHoleVia", "measured.largestDrill", "measured.padHole", "measured.smdPad", "measured.track", "measured.viaAnnularRing", "measured.viaDiameter", "measured.viaDrill", "outsideLimits", "limitsChecked"]); }++#[test]+fn height_check_matches_altium() {+ same("height_check", &["ok", "complete", "bodies", "parts", "tallest", "overLimit", "regionHits", "collisions", "tooClose", "minGapMm", "noBody", "heights"]);+}++#[test]+fn placement_check_matches_altium() {+ same("placement_check", &["ok", "parts", "courtyardOverlaps", "offOrOverEdge", "onMountingHoles", "nearEdge", "marginMm", "edgeMm"]);+}
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.6.0",+ "version": "0.7.0", "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.6.0",+ "standard": "0.7.0", "reference": { "bridge": "altium", "version": "0.2.78"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.6.0",+ "standard": "0.7.0", "reference": { "bridge": "altium", "version": "0.2.78"