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.23.0: engine 0.20.0, turned bodies by separating axes (#26); fusion 1.13.19
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.",