← Commit history

standard 0.7.1, engine 0.4.1: placement_check lists a part under a raised one as stacked, not a courtyard overlap, when their pads stay apart and no bodies meet in 3D (fusion-lane #19); Altium's lane and sentinel answers unchanged

Colby Knox ·7e8005c53c ·13d ago ·parent 0f5781d
8 files changed +63−8
README.md+2
@@ -157,6 +157,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.7.1: the engine (0.4.1)'s `placement_check` lists a part under a raised one (an Adom molecule on pins) as+`stacked`, not as a courtyard overlap, when their pads stay apart and no bodies meet in 3D (fusion-lane #19). 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`
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.4.0"+version = "0.4.1" dependencies = [  "roxmltree",  "serde_json",
engine/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"
engine/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 
engine/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());+    } }+
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.7.0",+  "version": "0.7.1",   "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.7.0",+ "standard": "0.7.1",  "reference": {   "bridge": "altium",   "version": "0.2.78"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.7.0",+ "standard": "0.7.1",  "reference": {   "bridge": "altium",   "version": "0.2.78"