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.13.2: engine 0.10.2, plan_fanout on a routed board
8 files changed
+82−12
README.md+6@@ -158,6 +158,12 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.13.2: the engine (0.10.2)'s `plan_fanout` works on a routed board (altium-lane #22):+- It skips pins already routed.+- It measures every via and stub against other nets' tracks, arcs and vias, not only pads.+- It keeps the exposed-pad grid off existing holes, and names the pours it runs into.++On the Bluetooth Sentinel, U4's plan clears all other-net copper by 0.206 mm or more (checked independently). Version 0.13.1: the engine (0.10.1)'s `place_decoupling` keeps each cap clear of the IC's planned fanout (`leaveFanout`), so placing caps first no longer blocks the escape stubs and vias (altium-lane: 5 conflicts on the Bluetooth Sentinel's U4, now 0). Version 0.13.0: the engine (0.10.0) fixes what fusion-lane found placing plans on a real board (#21, #22):
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.10.1"+version = "0.10.2" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.1"+version = "0.10.2" 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@@ -21,7 +21,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `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 |-| `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad anywhere on the board. From the KiCad bridge's place_tools |+| `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them | | `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 |@@ -95,7 +95,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.10.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.2"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/placetools.rs+69−5@@ -473,10 +473,39 @@ pub fn plan_fanout(input: &Value) -> Value { calls.entry(n.to_string()).or_default().push(json!(path)); rows.push(row); }+ // the board's routed copper: (net, layer or "" for every layer, shape, kind). Net-less tracks and arcs are a hatched+ // pour's pieces, which re-pour round new copper (altium-lane #22: a plan with "0 conflicts" shorted U4's routing)+ let mut routed: Vec<(String, String, Shape, &'static str)> = Vec::new();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !net(t).is_empty()) {+ routed.push((net(t), t["layer"].as_str().unwrap_or("").to_string(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), "track"));+ }+ for a in st["arcs"].as_array().into_iter().flatten().filter(|a| !net(a).is_empty()) {+ let (c, rad) = ((f(&a["center"]["x"]), f(&a["center"]["y"])), f(&a["radius"]));+ let (a1, mut a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+ if a2 <= a1 { a2 += 360.0; }+ let k = ((a2 - a1) / 10.0).ceil().max(1.0) as usize;+ let p = |deg: f64| (c.0 + rad * deg.to_radians().cos(), c.1 + rad * deg.to_radians().sin());+ for i in 0..k { routed.push((net(a), a["layer"].as_str().unwrap_or("").to_string(), Shape::Cap(p(a1 + (a2 - a1) * i as f64 / k as f64), p(a1 + (a2 - a1) * (i + 1) as f64 / k as f64), f(&a["width"]) / 2.0), "arc")); }+ }+ let holes: Vec<(P, f64)> = st["vias"].as_array().into_iter().flatten().map(|v| ((f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]) / 2.0))+ .chain(st["pads"].as_array().into_iter().flatten().filter(|p| f(&p["drill"]) > 0.0).map(|p| (at(p), f(&p["sizeX"]).max(f(&p["sizeY"])).max(f(&p["drill"])) / 2.0))).collect();+ for v in st["vias"].as_array().into_iter().flatten().filter(|v| !net(v).is_empty()) {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ routed.push((net(v), String::new(), Shape::Cap(c, c, f(&v["size"]) / 2.0), "via"));+ }+ let include_routed = args["includeRouted"].as_bool().unwrap_or(false); for &i in &signal { let p = pads[i]; let (pa, n) = (at(p), net(p)); if n.is_empty() { rows.push(json!({"pad": name(p), "net": "", "action": "skip", "why": "no net"})); continue; }+ // a pin its net's copper already reaches is routed: a stub from it would run into that routing+ if !include_routed {+ let ps = pad_shape(p);+ if routed.iter().any(|(rn, l, s, _)| *rn == n && (l.is_empty() || l == side) && ps.gap(s) <= 1e-6) {+ rows.push(json!({"pad": name(p), "net": n, "action": "skip", "why": "already routed"}));+ continue;+ }+ } if kind == "bga" { let ring = ((pa.0 - bx.0) / pitch).round().min(((bx.2 - pa.0) / pitch).round()).min(((pa.1 - bx.1) / pitch).round()).min(((bx.3 - pa.1) / pitch).round()) as i64; if ring == 0 && args["surfaceOuterRing"].as_bool() != Some(false) {@@ -519,16 +548,18 @@ pub fn plan_fanout(input: &Value) -> Value { let (w, h) = if (f(&p["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0 { (h, w) } else { (w, h) }; let vp = (via_size + 0.5).max(1.0); let (nx, ny) = ((((w - via_size) / vp).floor() as i64 + 1).max(1), (((h - via_size) / vp).floor() as i64 + 1).max(1));- let mut grid = Vec::new();+ let (mut grid, mut kept_clear) = (Vec::new(), 0); for a in 0..nx { for b in 0..ny { let v = (pa.0 + (a as f64 - (nx - 1) as f64 / 2.0) * vp, pa.1 + (b as f64 - (ny - 1) as f64 / 2.0) * vp);+ // clear of the holes already there, as thermal_vias keeps them (a grid on U4's 9 GND vias was hole-to-hole)+ if holes.iter().any(|(c, r)| ((c.0 - v.0).powi(2) + (c.1 - v.1).powi(2)).sqrt() < r + via_size / 2.0 + clearance - 1e-9) { kept_clear += 1; continue; } pieces.push((name(p), n.clone(), "via", Shape::Cap(v, v, via_size / 2.0))); calls.entry(n.clone()).or_default().push(json!([wp(v, side), wp(v, &escape)])); grid.push(json!([r3(v.0), r3(v.1)])); } }- rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp}));+ rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp, "keptClearOfHoles": kept_clear})); } // each via and stub against every other net's pad on the board (vias on every layer, stubs on the part's side) let all: Vec<(Value, Shape)> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().map(|p| { let s = pad_shape(&p); (p, s) }).collect();@@ -540,9 +571,29 @@ pub fn plan_fanout(input: &Value) -> Value { let g = s.gap(ps); if *what == "via" { worst = worst.min(g); } if g < clearance - 1e-9 {- conflicts.push(json!({"from": from, "what": what, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)}));+ conflicts.push(json!({"from": from, "what": what, "with": "pad", "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)})); } }+ // and against other nets' tracks, arcs and vias: on every layer for a via, on the part's side for a stub+ for (rn, l, rs, kind) in &routed {+ if rn == n || (*what == "stub" && !l.is_empty() && l != side) { continue; }+ let g = s.gap(rs);+ if g < clearance - 1e-9 {+ let (x0, y0, x1, y1) = rs.bounds();+ conflicts.push(json!({"from": from, "what": what, "with": kind, "net": rn, "layer": if l.is_empty() { "every" } else { l.as_str() }, "near": [r3((x0 + x1) / 2.0), r3((y0 + y1) / 2.0)], "gapMm": r3(g)}));+ }+ }+ }+ // other nets' pours the plan runs into: not conflicts (a pour re-pours round new copper), but until the repour the+ // tool's DRC sees the pour's pieces as shorts (altium-lane #22), and a stub across a pour can cut it in two+ let mut crosses: Vec<Value> = Vec::new();+ for p in st["polygons"].as_array().into_iter().flatten() {+ let (pn, pl) = (net(p), p["layer"].as_str().unwrap_or("").to_string());+ let ring: Vec<P> = p["points"].as_array().into_iter().flatten().map(|q| (f(&q[0]), f(&q[1]))).collect();+ if pn.is_empty() || ring.len() < 3 { continue; }+ let poly = Shape::Poly(ring);+ let hit = pieces.iter().any(|(_, n, what, s)| *n != pn && (*what == "via" || pl == side) && s.gap(&poly) <= 1e-9);+ if hit && !crosses.iter().any(|c| c["net"] == json!(pn) && c["layer"] == json!(pl)) { crosses.push(json!({"net": pn, "layer": pl})); } } let via_count = pieces.iter().filter(|x| x.2 == "via").count(); let calls: Vec<Value> = calls.into_iter().map(|(n, paths)| json!({"net": n, "layer": side, "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": paths})).collect();@@ -550,8 +601,8 @@ pub fn plan_fanout(input: &Value) -> Value { "style": if kind == "bga" { style } else { "stubs" }, "requestedStyle": want, "dogboneRoomMm": room, "dogboneFits": fits, "via": {"size": via_size, "drill": via_drill}, "widthMm": width, "clearanceMm": clearance, "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": via_count,- "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "warnings": warnings, "revision": st["revision"],- "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad on the board (vias on every layer, stubs on the part's side): drop or shorten those before routing. Tracks and pours are not checked: run DRC after. Via-in-pad needs filled and capped vias: tell the user first."})+ "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "crossesPours": crosses, "warnings": warnings, "revision": st["revision"],+ "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad, track, arc or via (vias on every layer, stubs on the part's side): apply nothing while it is not empty. Pins already reached by their net's copper are skipped (includeRouted plans them anyway); the exposed-pad grid steps round holes already there. crossesPours names other nets' pours the plan runs into: repour BEFORE running DRC (until then the pour's pieces read as shorts), then check that none of them split (splitNets). Via-in-pad needs filled and capped vias: tell the user first."}) } #[cfg(test)]@@ -718,6 +769,19 @@ mod tests { assert_eq!(r["pads"][0]["stubEnd"], json!([-2.8, 0.75])); assert_eq!(r["conflicts"].as_array().unwrap().iter().map(|c| c["pad"].as_str().unwrap()).collect::<Vec<_>>(), vec!["R1.1"], "{r}"); assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "R1"}}))["errorCode"], "not_a_package");+ // on a routed board (altium-lane #22): U2.1 already has its track, so it is skipped; another net's track across+ // U2.2's stub is a conflict; a via in the exposed pad's centre keeps the grid off it+ let mut routed = b.clone();+ routed["tracks"] = json!([{"net": "S0", "layer": "F.Cu", "width": 0.2, "start": {"x": -2.0, "y": 0.75}, "end": {"x": -4.0, "y": 0.75}},+ {"net": "Q", "layer": "F.Cu", "width": 0.2, "start": {"x": -2.6, "y": -2.0}, "end": {"x": -2.6, "y": 0.4}}]);+ routed["vias"] = json!([{"net": "GND", "size": 0.6, "drill": 0.3, "position": {"x": 0.0, "y": 0.0}}]);+ let r = plan_fanout(&json!({"board": routed, "args": {"ref": "U2"}}));+ assert_eq!(r["pads"][0]["why"], "already routed", "{r}");+ assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["with"] == "track" && c["net"] == "Q" && c["from"] == "U2.2"), "{r}");+ let grid = r["pads"].as_array().unwrap().iter().find(|p| p["action"] == "thermal-via-grid").unwrap();+ assert!(grid["keptClearOfHoles"].as_u64().unwrap() >= 1, "{grid}");+ assert!(grid["vias"].as_array().unwrap().iter().all(|v| (f(&v[0]).powi(2) + f(&v[1]).powi(2)).sqrt() >= 0.3 + 0.3 + 0.1 - 1e-9));+ assert_eq!(plan_fanout(&json!({"board": routed, "args": {"ref": "U2", "includeRouted": true}}))["pads"][0]["action"], "surface"); assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "U2", "escapeLayer": "In3.Cu"}}))["errorCode"], "no_such_layer"); }
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.13.1",+ "version": "0.13.2", "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.13.1",+ "standard": "0.13.2", "reference": { "bridge": "altium", "version": "0.2.79"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.13.1",+ "standard": "0.13.2", "reference": { "bridge": "altium", "version": "0.2.79"