← Commit history

standard 0.23.0: engine 0.20.0, turned bodies by separating axes (#26); fusion 1.13.19

Colby Knox ·c3c265238d ·12d ago ·parent 9fbe08f
10 files changed +99−22
README.md+3
@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.23.0: the engine (0.20.0) compares turned 3D bodies by their rectangles (separating axes), not their axis boxes, in+`height_check` and `placement_check`; a body row may give `{cx, cy, w, h, rotation}` or `corners` (kicad-lane #26: 70+false collisions round a 45-degree QFN). Regenerated with fusion-bridge 1.13.19. Version 0.22.4: an equivalents entry with a note is taken as deliberate, and the generator no longer warns about it. Regenerated with kicad-bridge 1.0.86, whose equivalents drop 8 stale entries. Version 0.22.3: generate.py warns when an equivalents entry maps a verb elsewhere while the bridge has its own same-named verb
docs/coverage.md+1−1
@@ -1,6 +1,6 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.82 (the reference), KiCad bridge 1.0.86, Fusion 360 bridge 1.13.18. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.82 (the reference), KiCad bridge 1.0.86, Fusion 360 bridge 1.13.19. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand. 
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.19.1"+version = "0.20.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.19.1"+version = "0.20.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+2−2
@@ -75,7 +75,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` |+| `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`. A turned body may give its footprint as `{cx, cy, w, h, rotation}` or four `corners`; bodies are then compared by their rectangles (separating axes), not their boxes | | `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` |@@ -115,7 +115,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.19.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.20.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/bodies.rs+84−11
@@ -9,19 +9,74 @@ 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.+/// 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; a+/// turned body also has its footprint as a rectangle's four corners (`turned`), and x/y extent is their bounding box. #[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 }+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, pub turned: Option<Vec<(f64, f64)>> } -/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+/// A body row's turned rectangle: `{cx, cy, w, h, rotation}` (degrees counterclockwise) or `corners` (four [x, y]).+fn turned(r: &Value) -> Option<Vec<(f64, f64)>> {+    if let Some(cs) = r["corners"].as_array().filter(|a| a.len() == 4) { return Some(cs.iter().map(|c| (f(&c[0]), f(&c[1]))).collect()); }+    if r["cx"].is_number() && r["w"].is_number() && r["h"].is_number() {+        let (cx, cy, hw, hh, a) = (f(&r["cx"]), f(&r["cy"]), f(&r["w"]) / 2.0, f(&r["h"]) / 2.0, f(&r["rotation"]).to_radians());+        let (c, s) = (a.cos(), a.sin());+        return Some([(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(u, v)| (cx + u * c - v * s, cy + u * s + v * c)).collect());+    }+    None+}++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm, and for a turned body `{cx, cy, w, h,+/// rotation}` or `corners`) 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"]),+    rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| {+        let t = turned(r);+        let (x0, y0, x1, y1) = match &t {+            Some(cs) => cs.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),+            None => (f(&r["x0"]).min(f(&r["x1"])), f(&r["y0"]).min(f(&r["y1"])), f(&r["x0"]).max(f(&r["x1"])), f(&r["y0"]).max(f(&r["y1"]))),+        };+        BodyBox { part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"), x0, y0, x1, y1, z0: f(&r["z0"]), z1: f(&r["z1"]), turned: t }     }).collect() } +/// How two bodies' footprints meet in x and y: (true, how deep) when they overlap, else (false, the gap between them).+/// Axis boxes compare as boxes; when either is turned, both are compared as rectangles by separating axes, so a part at+/// 45 degrees no longer counts its box's empty corners (kicad-lane #26: 70 false collisions round a turned QFN). Pure.+pub fn xy_meet(a: &BodyBox, b: &BodyBox) -> (bool, f64) {+    if a.turned.is_none() && b.turned.is_none() {+        let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+        let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+        return 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()) };+    }+    let poly = |x: &BodyBox| x.turned.clone().unwrap_or_else(|| vec![(x.x0, x.y0), (x.x1, x.y0), (x.x1, x.y1), (x.x0, x.y1)]);+    let (pa, pb) = (poly(a), poly(b));+    // separating axes: each edge's normal of either rectangle+    let mut sep = f64::MIN;+    for p in [&pa, &pb] {+        for i in 0..p.len() {+            let (e0, e1) = (p[i], p[(i + 1) % p.len()]);+            let (nx, ny) = (e0.1 - e1.1, e1.0 - e0.0);+            let l = (nx * nx + ny * ny).sqrt();+            if l < 1e-12 { continue; }+            let proj = |q: &Vec<(f64, f64)>| q.iter().map(|v| (v.0 * nx + v.1 * ny) / l).fold((f64::MAX, f64::MIN), |m, d| (m.0.min(d), m.1.max(d)));+            let ((a0, a1), (b0, b1)) = (proj(&pa), proj(&pb));+            sep = sep.max((b0 - a1).max(a0 - b1));+        }+    }+    if sep < 0.0 { return (true, -sep); }+    // apart: the shortest distance between their edges+    let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let l2 = dx * dx + dy * dy;+        let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+        ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+    };+    let mut d = f64::MAX;+    for (p, q) in [(&pa, &pb), (&pb, &pa)] {+        for v in p.iter() { for i in 0..q.len() { d = d.min(seg(*v, q[i], q[(i + 1) % q.len()])); } }+    }+    (false, d)+}+ /// 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")?;@@ -69,9 +124,7 @@ pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) {             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()) };+            let (hit, d) = xy_meet(a, b);             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)]);@@ -135,7 +188,7 @@ 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 }+        BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1, turned: None }     }      #[test]@@ -184,4 +237,24 @@ mod tests {         assert_eq!(r["complete"], json!(false));         assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies");     }++    #[test]+    fn turned_bodies_meet_by_their_rectangles_not_their_boxes() {+        // a 4 mm QFN at 45 degrees and a cap in its box's empty corner: apart, not a collision (kicad-lane #26)+        let rows = vec![json!({"ref": "U1", "side": "top", "cx": 0.0, "cy": 0.0, "w": 4.0, "h": 4.0, "rotation": 45.0, "z0": 0.0, "z1": 0.9}),+                        json!({"ref": "C1", "side": "top", "x0": 2.0, "y0": 2.0, "x1": 2.6, "y1": 2.3, "z0": 0.0, "z1": 0.5})];+        let bs = boxes(&rows);+        assert!((bs[0].x1 - 2.0 * 2f64.sqrt()).abs() < 1e-9, "the box still covers the turned body");+        let (hit, d) = xy_meet(&bs[0], &bs[1]);+        assert!(!hit && d > 0.5, "{hit} {d}");+        let (col, _) = clashes(&bs, 0.2);+        assert!(col.is_empty());+        // the same cap moved onto the QFN's edge collides+        let near = boxes(&[rows[0].clone(), json!({"ref": "C1", "side": "top", "x0": 1.2, "y0": 1.2, "x1": 1.8, "y1": 1.5, "z0": 0.0, "z1": 0.5})]);+        assert!(xy_meet(&near[0], &near[1]).0);+        // corners work too+        let c = boxes(&[json!({"ref": "U2", "corners": [[0, 0], [2, 0], [2, 1], [0, 1]], "z0": 0, "z1": 1})]);+        assert_eq!((c[0].x1, c[0].y1), (2.0, 1.0));+    } }+
engine/src/placement.rs+2−1
@@ -111,7 +111,8 @@ pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &st     if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; }     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 false; }-    !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)))+    // bodies meet when their footprints come within twice the margin (turned ones by their rectangles) and their heights overlap+    !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && p.z0.max(q.z0) < p.z1.min(q.z1) })) }  /// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.22.4",+  "version": "0.23.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+2−2
@@ -1,5 +1,5 @@ {- "standard": "0.22.4",+ "standard": "0.23.0",  "reference": {   "bridge": "altium",   "version": "0.2.82"@@ -7,7 +7,7 @@  "proposed": 56,  "compared": {   "kicad": "1.0.86",-  "fusion360": "1.13.18"+  "fusion360": "1.13.19"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",
verbs/standard.json+2−2
@@ -1,5 +1,5 @@ {- "standard": "0.22.4",+ "standard": "0.23.0",  "reference": {   "bridge": "altium",   "version": "0.2.82"@@ -7,7 +7,7 @@  "proposed": 56,  "compared": {   "kicad": "1.0.86",-  "fusion360": "1.13.18"+  "fusion360": "1.13.19"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",