← 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

Colby Knox ·e7b68d92a3 ·13d ago ·parent 1583b38
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.",