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.9.0, engine 0.6.0: plan_route in the engine, from fusion-bridge's grid search (#12, #14): any listed copper layers with through vias, pads by their real outline, the board's clearance from rules, route_net arguments out; on the Sentinel its routes and vias keep the 0.1 mm rule (checked independently). Regenerated with fusion 1.13.4
10 files changed
+345−16
README.md+5−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`, `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+`current_capacity`, `height_check`, `placement_check`, `thermal_vias`, `via_fence` and `plan_route`, moved over from the+Altium and Fusion bridges (the first seven give the same answers as the Altium bridge on its test boards). It also carries board-file writers (`export_gencad`, `export_dxf`, `import_svg`, `generate_body`) 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@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.9.0: the engine (0.6.0) plans a route between two pads (`plan_route`), from the Fusion bridge's grid search, on+any listed copper layers; on the Bluetooth Sentinel its routes and vias keep the board's 0.1 mm clearance (checked+independently). Version 0.8.1: the engine (0.5.1)'s marking font draws D and B so they read as D and B, not "IO" and 8 (PR #3). Version 0.8.0: the engine (0.5.0) writes a part's 3D body as STEP from its package dimensions (`generate_body`, from the Altium bridge's body3d.rs, PR #2, #20); it matches Fusion's generator on a 0603, SOIC-8, QFN-32 and QFP-48.
docs/coverage.md+1−1@@ -1,6 +1,6 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.72, Fusion 360 bridge 1.13.3. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.73, Fusion 360 bridge 1.13.4. 54 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.5.1"+version = "0.6.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.5.1"+version = "0.6.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+4−3@@ -18,6 +18,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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; `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_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`, keeping `widthMm/2` + the board's clearance from other nets' copper (pads by their real outline), inside the outline, no via in a pad. `routeNet` is route_net's arguments, unchanged; pours are not obstacles (repour after) | | `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 | @@ -25,8 +26,8 @@ The via plans size their vias from `args.size`/`drill`, else the board's via rul or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`. 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.+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling and plan_fanout)+come over a module at a time. ## Writers @@ -89,7 +90,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.5.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.6.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/lib.rs+3−1@@ -16,6 +16,7 @@ pub mod geom; pub mod impedance; pub mod input; pub mod placement;+pub mod route; 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@@ -23,7 +24,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", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "plan_route", "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 {@@ -41,6 +42,7 @@ fn answer(check: &str, input: &Value) -> Value { "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input), "placement_check" => placement::placement_check(input),+ "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input),
engine/src/route.rsadded+323@@ -0,0 +1,323 @@+//! A route between two pads of a net, planned on the board model and answered as route_net's arguments: a bounded+//! orthogonal A* over a grid on any copper layers the caller lists, with through vias between them. Every step keeps+//! `width/2 + clearance` from copper of other nets on its layer (pads by their real outline, tracks, arcs, vias), stays+//! inside the outline by the edge clearance, and no via lands in a pad. From the Fusion bridge's routing_planner.py+//! (fusion-lane, #12), which searched two layers of an EAGLE board; this one reads the standard board model. A plan+//! only: nothing changes until the bridge runs route_net with `routeNet`.+use serde_json::{json, Value};+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap};++type P = (f64, f64);++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++fn seg_point(p: P, a: P, b: P) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ ((a.0 + t * dx - p.0).powi(2) + (a.1 + t * dy - p.1).powi(2)).sqrt()+}++/// The distance between two segments (0 when they cross).+fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+ let cross = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+ if cross(a, b, c) * cross(a, b, d) < 0.0 && cross(c, d, a) * cross(c, d, b) < 0.0 { return 0.0; }+ seg_point(a, c, d).min(seg_point(b, c, d)).min(seg_point(c, a, b)).min(seg_point(d, a, b))+}++fn inside(p: P, ring: &[P]) -> bool {+ let mut c = false;+ for i in 0..ring.len() {+ let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ }+ c+}++/// Copper as a shape: a capsule (a segment with a half-width: tracks, round pads, vias) or a polygon (rect pads, a+/// custom pad's copperPoints).+#[derive(Clone)]+enum Shape { Cap(P, P, f64), Poly(Vec<P>) }++impl Shape {+ /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.+ fn dist(&self, a: P, b: P) -> f64 {+ match self {+ Shape::Cap(c, d, r) => seg_seg(a, b, *c, *d) - r,+ Shape::Poly(ring) => {+ if inside(a, ring) || inside(b, ring) { return 0.0; }+ (0..ring.len()).map(|i| seg_seg(a, b, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)+ }+ }+ }+ fn bounds(&self) -> (f64, f64, f64, f64) {+ match self {+ Shape::Cap(c, d, r) => (c.0.min(d.0) - r, c.1.min(d.1) - r, c.0.max(d.0) + r, c.1.max(d.1) + r),+ Shape::Poly(ring) => ring.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))),+ }+ }+}++/// A pad's copper: copperPoints when the bridge sent them, else its shape turned with it.+fn pad_shape(p: &Value) -> Shape {+ let (x, y, sx, sy, rot) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]).to_radians());+ let pts: Vec<P> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+ if pts.len() >= 3 { return Shape::Poly(pts); }+ let (c, s) = (rot.cos(), rot.sin());+ match p["shape"].as_str() {+ Some("round") | Some("circle") => {+ let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy) / 2.0);+ let (ux, uy) = if sx >= sy { (c * l, s * l) } else { (-s * l, c * l) };+ Shape::Cap((x - ux, y - uy), (x + ux, y + uy), w)+ }+ _ => Shape::Poly([(-sx, -sy), (sx, -sy), (sx, sy), (-sx, sy)].iter().map(|(u, v)| (x + u / 2.0 * c - v / 2.0 * s, y + u / 2.0 * s + v / 2.0 * c)).collect()),+ }+}++/// The copper layers a pad is on: every one for a through-hole pad (`*.Cu` or a drill), else its own.+fn pad_layers(p: &Value, all: &[String]) -> Vec<String> {+ let l = p["layer"].as_str().unwrap_or("");+ if l == "*.Cu" || f(&p["drill"]) > 0.0 { all.to_vec() } else { vec![l.to_string()] }+}++/// plan_route: input {board, rules?, args: {net, from, to, widthMm?, layers?, allowVias?, viaSizeMm?, viaDrillMm?, gridMm?,+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad.+pub fn plan_route(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+ let Some(net) = args["net"].as_str() else { return crate::input::refuse("bad_args", "net is required") };+ if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false))).unwrap_or(false) {+ return crate::input::refuse("no_such_net", &format!("{net} is not a net on this board"));+ }+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let key = |p: &Value| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+ let mut ends = Vec::new();+ for k in ["from", "to"] {+ let Some(want) = args[k].as_str() else { return crate::input::refuse("bad_args", &format!("{k} is a pad as Ref.Pad (U1.3)")) };+ let Some(p) = pads.iter().find(|p| key(p).eq_ignore_ascii_case(want)) else { return crate::input::refuse("no_such_pad", &format!("{want} is not a pad on this board")) };+ if !p["net"].as_str().map(|n| n.eq_ignore_ascii_case(net)).unwrap_or(false) {+ return json!({"success": true, "found": false, "reason": format!("{want} is on {}, not {net}", p["net"].as_str().unwrap_or("no net"))});+ }+ ends.push(p.clone());+ }+ if key(&ends[0]).eq_ignore_ascii_case(&key(&ends[1])) { return crate::input::refuse("bad_args", "from and to are the same pad"); }+ let layers: Vec<String> = args["layers"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["F.Cu".into(), "B.Cu".into()]);+ if layers.is_empty() || layers.iter().any(|l| !l.ends_with(".Cu") || l == "*.Cu") { return crate::input::refuse("bad_args", "layers are copper layers by name: [\"F.Cu\", \"In1.Cu\", \"B.Cu\"]"); }+ let width = num("widthMm", 0.25);+ let (via_size, via_drill) = (num("viaSizeMm", 0.6), num("viaDrillMm", 0.3));+ if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaSizeMm"); }+ let grid = num("gridMm", 0.5);+ if !(0.05..=5.0).contains(&grid) { return crate::input::refuse("bad_args", "gridMm is 0.05 to 5"); }+ let allow_vias = args["allowVias"].as_bool().unwrap_or(true) && layers.len() > 1;+ let via_cost = num("viaCost", 5.0);+ let max_nodes = args["maxNodes"].as_u64().unwrap_or(50_000).clamp(1, 500_000) as usize;+ // the board's own clearance decides; a caller's clearanceMm can only raise it+ let rule = crate::fab::min_clearance(&crate::input::rules(input));+ let clearance = num("clearanceMm", 0.0).max(rule.unwrap_or(0.2));+ let edge = num("edgeClearanceMm", 0.3);+ let ring = crate::coppermap::outline_ring(&st);+ if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); }++ // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's+ // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper,+ // so neither blocks a route: repour after placing it (the reply says so)+ let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(net) || (n.is_empty() && v.get("start").or(v.get("center")).is_some())).unwrap_or(false);+ let mut ob: Vec<(Shape, Vec<String>, (f64, f64, f64, f64))> = Vec::new();+ let mut push = |s: Shape, ls: Vec<String>| { let b = s.bounds(); ob.push((s, ls, b)); };+ for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &layers)); }+ for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| !same(t)) {+ push(Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), vec![t["layer"].as_str().unwrap_or("").to_string()]);+ }+ for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| !same(a)) {+ let (cx, cy, r) = (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 n = ((a2 - a1) / 10.0).ceil().max(1.0) as usize;+ let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+ for i in 0..n { push(Shape::Cap(p(a1 + (a2 - a1) * i as f64 / n as f64), p(a1 + (a2 - a1) * (i + 1) as f64 / n as f64), f(&a["width"]) / 2.0), vec![a["layer"].as_str().unwrap_or("").to_string()]); }+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| !same(v)) {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ push(Shape::Cap(c, c, f(&v["size"]) / 2.0), layers.clone());+ }+ let all_pads: Vec<Shape> = pads.iter().map(pad_shape).collect();++ // the grid: lines every gridMm over the outline, plus lines through both pad centres+ let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let cells = ((x1 - x0) / grid + 3.0) * ((y1 - y0) / grid + 3.0) * layers.len() as f64;+ if cells > 2_000_000.0 { return crate::input::refuse("search_budget", "the grid has too many cells: use a coarser gridMm"); }+ let axis = |lo: f64, hi: f64, extra: [f64; 2]| -> Vec<f64> {+ let mut v: Vec<f64> = (0..=((hi - lo) / grid).floor() as usize).map(|i| r3(lo + i as f64 * grid)).collect();+ v.extend(extra.iter().map(|e| r3(*e)));+ v.sort_by(|a, b| a.partial_cmp(b).unwrap());+ v.dedup_by(|a, b| (*a - *b).abs() < 1e-9);+ v+ };+ let (sp, gp) = ((f(&ends[0]["x"]), f(&ends[0]["y"])), (f(&ends[1]["x"]), f(&ends[1]["y"])));+ let xs = axis(x0, x1, [sp.0, gp.0]);+ let ys = axis(y0, y1, [sp.1, gp.1]);+ let idx = |v: &[f64], t: f64| v.iter().position(|x| (x - r3(t)).abs() < 1e-9).unwrap();+ let (start, goal) = ((idx(&xs, sp.0), idx(&ys, sp.1)), (idx(&xs, gp.0), idx(&ys, gp.1)));+ let start_layers: Vec<usize> = pad_layers(&ends[0], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+ let goal_layers: Vec<usize> = pad_layers(&ends[1], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+ if start_layers.is_empty() || goal_layers.is_empty() {+ return json!({"success": true, "found": false, "reason": "a pad is on no layer the route may use: add its layer to layers"});+ }+ if !allow_vias && !start_layers.iter().any(|l| goal_layers.contains(l)) {+ return json!({"success": true, "found": false, "reason": "the pads share no layer and allowVias is false"});+ }++ let edge_ok = |a: P, b: P, r: f64| inside(a, &ring) && inside(b, &ring)+ && (0..ring.len()).all(|k| seg_seg(a, b, ring[k], ring[(k + 1) % ring.len()]) >= edge + r - 1e-9);+ let clear_on = |a: P, b: P, r: f64, layer: &str| {+ let (bx0, by0, bx1, by1) = (a.0.min(b.0) - r - clearance, a.1.min(b.1) - r - clearance, a.0.max(b.0) + r + clearance, a.1.max(b.1) + r + clearance);+ ob.iter().filter(|(_, ls, bb)| ls.iter().any(|l| l == layer) && !(bb.2 < bx0 || bb.0 > bx1 || bb.3 < by0 || bb.1 > by1))+ .all(|(s, _, _)| s.dist(a, b) >= r + clearance - 1e-9)+ };+ let mut cache: HashMap<(usize, usize, usize, usize, usize, usize), bool> = HashMap::new();+ let mut step_ok = |u: (usize, usize, usize), v: (usize, usize, usize)| -> bool {+ let k = if u <= v { (u.0, u.1, u.2, v.0, v.1, v.2) } else { (v.0, v.1, v.2, u.0, u.1, u.2) };+ if let Some(b) = cache.get(&k) { return *b; }+ let (a, b) = ((xs[u.0], ys[u.1]), (xs[v.0], ys[v.1]));+ let ok = if u.2 != v.2 {+ // a through via: clear on every layer, inside the outline, in no pad+ let r = via_size / 2.0;+ edge_ok(a, a, r) && layers.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)+ } else {+ edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])+ };+ cache.insert(k, ok);+ ok+ };+ let h = |u: (usize, usize, usize)| (xs[u.0] - gp.0).abs() + (ys[u.1] - gp.1).abs() + if goal_layers.contains(&u.2) { 0.0 } else { via_cost };+ let q = |v: f64| (v * 1e6).round() as i64;+ let mut heap: BinaryHeap<Reverse<(i64, i64, (usize, usize, usize))>> = BinaryHeap::new();+ let mut cost: HashMap<(usize, usize, usize), f64> = HashMap::new();+ let mut parent: HashMap<(usize, usize, usize), (usize, usize, usize)> = HashMap::new();+ for &l in &start_layers { let u = (start.0, start.1, l); cost.insert(u, 0.0); heap.push(Reverse((q(h(u)), 0, u))); }+ let mut seen: std::collections::HashSet<(usize, usize, usize)> = Default::default();+ let mut found = None;+ while let Some(Reverse((_, _, u))) = heap.pop() {+ if !seen.insert(u) { continue; }+ if (u.0, u.1) == goal && goal_layers.contains(&u.2) { found = Some(u); break; }+ if seen.len() >= max_nodes { break; }+ let g = cost[&u];+ let mut nb = Vec::new();+ if u.0 + 1 < xs.len() { nb.push((u.0 + 1, u.1, u.2)); }+ if u.0 > 0 { nb.push((u.0 - 1, u.1, u.2)); }+ if u.1 + 1 < ys.len() { nb.push((u.0, u.1 + 1, u.2)); }+ if u.1 > 0 { nb.push((u.0, u.1 - 1, u.2)); }+ if allow_vias { for l in 0..layers.len() { if l != u.2 { nb.push((u.0, u.1, l)); } } }+ for v in nb {+ if seen.contains(&v) || !step_ok(u, v) { continue; }+ let step = if v.2 != u.2 { via_cost } else { (xs[v.0] - xs[u.0]).abs() + (ys[v.1] - ys[u.1]).abs() };+ let ng = g + step;+ if ng >= cost.get(&v).copied().unwrap_or(f64::MAX) - 1e-9 { continue; }+ cost.insert(v, ng);+ parent.insert(v, u);+ heap.push(Reverse((q(ng + h(v)), q(ng), v)));+ }+ }+ let revision = st["revision"].clone();+ let Some(goal_node) = found else {+ let why = if seen.len() >= max_nodes { "no path within the search limit (maxNodes): try a coarser gridMm or a higher maxNodes" } else { "no legal path on this grid: other copper, the outline or the clearance blocks every way. A fine-pitch pad (0.5 mm pitch) usually needs gridMm 0.1 to escape; more layers give more room" };+ return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision});+ };+ let mut path = vec![goal_node];+ while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }+ path.reverse();+ // keep the corners and the layer changes+ let mut keep = vec![path[0]];+ for w in path.windows(3) {+ let (a, b, c) = (w[0], w[1], w[2]);+ if a.2 == b.2 && b.2 == c.2 && ((a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1)) { continue; }+ keep.push(b);+ }+ if path.len() > 1 { keep.push(*path.last().unwrap()); }+ let mut way = vec![json!({"pad": key(&ends[0])})];+ let mut vias = Vec::new();+ let mut length = 0.0;+ let last = keep.len() - 1;+ for (i, w) in keep.windows(2).enumerate() {+ let (a, b) = (w[0], w[1]);+ let (pa, pb) = ((xs[a.0], ys[a.1]), (xs[b.0], ys[b.1]));+ if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else { length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt(); }+ // every corner and layer change; the goal itself is the closing {pad} waypoint+ if i + 1 < last { way.push(json!({"x": pb.0, "y": pb.1, "layer": layers[b.2]})); }+ }+ way.push(json!({"pad": key(&ends[1])}));+ json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias,+ "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},+ "planner": {"algorithm": "bounded orthogonal A*", "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+ "revision": revision,+ "_hint": "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board(extra_tracks: Value) -> Value {+ json!({"nets": ["SIG", "GND"], "revision": "r1", "outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+ "pads": [{"ref": "U1", "name": "1", "x": 2.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+ {"ref": "J1", "name": "1", "x": 18.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+ {"ref": "J1", "name": "2", "x": 18.0, "y": 2.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "SIG"}],+ "tracks": extra_tracks, "arcs": [], "vias": []})+ }++ #[test]+ fn a_clear_board_is_one_straight_track() {+ let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ assert_eq!(r["found"], json!(true), "{r}");+ assert_eq!(r["vias"], 0);+ assert!((r["lengthMm"].as_f64().unwrap() - 16.0).abs() < 1e-9);+ assert_eq!(r["routeNet"]["paths"][0][0], json!({"pad": "U1.1"}));+ assert_eq!(r["routeNet"]["paths"][0].as_array().unwrap().last().unwrap(), &json!({"pad": "J1.1"}));+ assert_eq!(r["routeNet"]["layer"], "F.Cu");+ }++ #[test]+ fn a_wall_on_top_is_crossed_on_the_bottom_or_not_at_all() {+ // a GND track across the whole board on F.Cu+ let wall = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+ let r = plan_route(&json!({"board": board(wall.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ assert_eq!(r["found"], json!(true), "{r}");+ assert_eq!(r["vias"], 2, "down to B.Cu and back up: {r}");+ // every point of the path clears the wall on F.Cu+ for w in r["routeNet"]["paths"][0].as_array().unwrap() { if w["layer"] == "F.Cu" { assert!((f(&w["x"]) - 10.0).abs() > 0.25 + 0.2 + 0.125 - 1e-9, "{w}"); } }+ let none = plan_route(&json!({"board": board(wall), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "allowVias": false}}));+ assert_eq!(none["found"], json!(false));+ }++ #[test]+ fn pads_on_different_layers_take_a_via_and_errors_are_named() {+ let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.2"}}));+ assert_eq!(r["found"], json!(true), "{r}");+ assert_eq!(r["vias"], 1);+ // the via is clear of every pad+ let v = &r["viaAt"][0];+ for (px, py) in [(2.0, 5.0), (18.0, 5.0), (18.0, 2.0)] { assert!(((f(&v[0]) - px).powi(2) + (f(&v[1]) - py).powi(2)).sqrt() > 0.3 + 0.5, "{v}"); }+ assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U9.1", "to": "J1.1"}}))["errorCode"], "no_such_pad");+ assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "NOPE", "from": "U1.1", "to": "J1.1"}}))["errorCode"], "no_such_net");+ let mut b = board(json!([]));+ b["pads"][1]["net"] = json!("GND");+ assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["found"], json!(false));+ }++ #[test]+ fn three_layers_when_asked() {+ // walls on F.Cu and B.Cu: only In1.Cu crosses+ let walls = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}},+ {"net": "GND", "layer": "B.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+ let two = plan_route(&json!({"board": board(walls.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ assert_eq!(two["found"], json!(false));+ let three = plan_route(&json!({"board": board(walls), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "layers": ["F.Cu", "In1.Cu", "B.Cu"]}}));+ assert_eq!(three["found"], json!(true), "{three}");+ assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu"));+ }+}
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.8.1",+ "version": "0.9.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+3−3@@ -1,13 +1,13 @@ {- "standard": "0.8.1",+ "standard": "0.9.0", "reference": { "bridge": "altium", "version": "0.2.79" }, "proposed": 54, "compared": {- "kicad": "1.0.72",- "fusion360": "1.13.3"+ "kicad": "1.0.73",+ "fusion360": "1.13.4" }, "tiers": { "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",
verbs/standard.json+3−3@@ -1,13 +1,13 @@ {- "standard": "0.8.1",+ "standard": "0.9.0", "reference": { "bridge": "altium", "version": "0.2.79" }, "proposed": 54, "compared": {- "kicad": "1.0.72",- "fusion360": "1.13.3"+ "kicad": "1.0.73",+ "fusion360": "1.13.4" }, "tiers": { "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",