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

Colby Knox ·e7b8a3e85d ·13d ago ·parent 2a3b949
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"