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.18.0: engine 0.15.0 adds route_pair and length_tune
11 files changed
+350−8
README.md+3@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.18.0: the engine (0.15.0) adds `route_pair` and `length_tune` from the Altium bridge, as plans in route_net arguments.+On the Bluetooth Sentinel, `length_tune` lengthens IO_Red_LED6 by exactly 3.0 mm with every bump at the board's 0.1 mm+from other copper (checked independently), and says when a net has no room. Version 0.17.1: the engine (0.14.1)'s `find_space {check: true}` judges overlaps and the outline as placement_check does: parts stacked under a raised molecule apart, off the outline by pad centres (fusion-lane on BQ25792). Version 0.17.0: the engine (0.14.0) adds `find_space` and `check_orientation` from the Altium bridge. On the Bluetooth Sentinel the
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.14.1"+version = "0.15.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.14.1"+version = "0.15.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+3−1@@ -24,10 +24,12 @@ 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 | | `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 |+| `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `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, 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 (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `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 |@@ -102,7 +104,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.14.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.15.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/lengthtune.rsadded+138@@ -0,0 +1,138 @@+//! Length tuning: a serpentine in the net's longest straight track that has room beside it, its bumps sized to add+//! exactly the length a target (or another net's length) asks for, every bump clear of other copper and inside the+//! board, or a refusal that says why. From the Altium bridge's lengthtune.rs; the plan is answered as the track to+//! remove and the route_net arguments that replace it, for the bridge to apply in one undo step.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++/// The serpentine's points replacing a track a->b: `n` rectangular bumps of height `amp` to the side `side` (+1 left,+/// -1 right), each `pitch` long along the track, centred on it. Pure.+pub fn meander(a: P, b: P, n: usize, amp: f64, pitch: f64, side: f64) -> Vec<P> {+ let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+ let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+ let (nx, ny) = (-uy * side, ux * side);+ let at = |s: f64, h: f64| (a.0 + ux * s + nx * h, a.1 + uy * s + ny * h);+ let start = (len - n as f64 * pitch) / 2.0;+ let mut pts = vec![a];+ for k in 0..n {+ let s = start + k as f64 * pitch;+ pts.push(at(s, 0.0));+ pts.push(at(s, amp));+ pts.push(at(s + pitch / 2.0, amp));+ pts.push(at(s + pitch / 2.0, 0.0));+ }+ pts.push(b);+ pts.dedup_by(|p, q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9);+ pts+}++/// How many bumps and how tall, to add `extra` mm along a track `len` long: as few bumps as fit at `max_amp`, each+/// `pitch` long, leaving `margin` at both ends. None when it cannot fit. Pure.+pub fn sizing(extra: f64, len: f64, pitch: f64, max_amp: f64, margin: f64) -> Option<(usize, f64)> {+ if extra <= 0.0 { return None; }+ let room = ((len - 2.0 * margin) / pitch).floor() as usize;+ if room == 0 { return None; }+ let n = ((extra / (2.0 * max_amp)).ceil() as usize).max(1);+ if n > room { return None; }+ Some((n, extra / (2.0 * n as f64)))+}++/// length_tune: input {board, rules?, args: {net, targetMm? | matchTo?, maxAmplitudeMm?, pitchMm?, clearance?}}.+pub fn length_tune(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some(net) = args["net"].as_str().map(String::from) else { return crate::input::refuse("bad_args", "net is the net to lengthen") };+ let lens = crate::lengths::lengths(&st);+ let len_of = |n: &str| lens.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| f(&v["lengthMm"]));+ let Some(now) = len_of(&net) else { return crate::input::refuse("not_routed", &format!("{net} has no routed track")) };+ let target = match (args["targetMm"].as_f64(), args["matchTo"].as_str()) {+ (Some(t), _) => t,+ (None, Some(m)) => match len_of(m) { Some(l) => l, None => return crate::input::refuse("not_routed", &format!("{m} has no routed track to match")) },+ _ => return crate::input::refuse("bad_args", "targetMm (mm) or matchTo (a net) says how long it should be"),+ };+ let extra = target - now;+ if extra <= 0.01 { return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "_hint": "Already at or past the target: tuning only lengthens. Shorten the other net, or route this one more directly."}); }+ let num = |k: &str, d: f64| args[k].as_f64().unwrap_or(d);+ let rule = crate::fab::routing_clearance(&crate::input::rules(input));+ let clearance = num("clearance", 0.0).max(rule.unwrap_or(0.2));+ let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(&net)).unwrap_or(false);+ // other nets' copper by layer ("*" every layer): routed tracks (a hatched pour's net-less pieces re-pour round the+ // bumps), vias, and pads by their real outline+ let mut other: Vec<(String, Shape)> = Vec::new();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !same(t) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ other.push((t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+ }+ for v in st["vias"].as_array().into_iter().flatten().filter(|v| !same(v)) {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ other.push(("*".into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+ }+ for p in st["pads"].as_array().into_iter().flatten().filter(|p| !same(p)) {+ let l = if p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0 { "*".to_string() } else { p["layer"].as_str().unwrap_or("").to_string() };+ other.push((l, pad_shape(p)));+ }+ let ring = crate::coppermap::outline_ring(&st);+ // the net's tracks, longest first; for each, a side where the serpentine fits+ let mut tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(same).collect();+ let tlen = |t: &Value| ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();+ tracks.sort_by(|a, b| tlen(b).partial_cmp(&tlen(a)).unwrap());+ let mut why = Vec::new();+ for t in &tracks {+ let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+ let w = f(&t["width"]);+ let layer = t["layer"].as_str().unwrap_or("F.Cu").to_string();+ let pitch = num("pitchMm", (4.0 * w).max(4.0 * clearance + 2.0 * w));+ let Some((n, amp)) = sizing(extra, tlen(t), pitch, num("maxAmplitudeMm", 3.0), 2.0 * w) else { why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "too short for the bumps this needs at this amplitude"})); continue };+ for side in [1.0, -1.0] {+ let pts = meander(a, b, n, amp, pitch, side);+ let clear = pts.windows(2).all(|s| other.iter().filter(|o| o.0 == layer || o.0 == "*").all(|o| o.1.dist(s[0], s[1]) >= w / 2.0 + clearance - 1e-9))+ && (ring.len() < 3 || pts.iter().all(|p| crate::route::inside(*p, &ring)));+ if !clear { continue; }+ let added: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum::<f64>() - tlen(t);+ let way: Vec<Value> = pts.iter().map(|p| json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})).collect();+ return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "expectedMm": r3(now + added), "clearanceMm": clearance,+ "plan": {"layer": layer, "replaces": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "side": if side > 0.0 { "left" } else { "right" }, "addsMm": r3(added)},+ "remove": {"tracks": [{"net": net, "layer": layer, "start": {"x": a.0, "y": a.1}, "end": {"x": b.0, "y": b.1}}]},+ "routeNet": {"net": net, "layer": layer, "width": w, "paths": [way]}, "revision": st["revision"],+ "_hint": "A plan: remove the one track in `remove` and route `routeNet` in its place, in one undo step, with expectedRevision: revision. net_lengths then shows the new length; every bump keeps clearanceMm from other nets' pads, routed tracks and vias."});+ }+ why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "no clear room on either side for the bumps"}));+ }+ json!({"success": false, "errorCode": "no_room", "error": format!("no track of {net} has room for {:.2} mm more", extra), "lengthMm": r3(now), "targetMm": r3(target), "tried": why,+ "_hint": "Lower maxAmplitudeMm for more, shorter bumps, raise it for fewer, or reroute the net with a longer straight run in open space."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_meander_adds_exactly_twice_its_height_per_bump() {+ let (n, amp) = sizing(6.0, 30.0, 1.2, 1.0, 0.4).unwrap();+ assert_eq!(n, 3);+ assert!((amp - 1.0).abs() < 1e-9);+ let pts = meander((0.0, 0.0), (30.0, 0.0), n, amp, 1.2, 1.0);+ let len: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum();+ assert!((len - 36.0).abs() < 1e-9, "{len}");+ assert!(sizing(100.0, 5.0, 1.2, 1.0, 0.4).is_none());+ assert!(sizing(-1.0, 30.0, 1.2, 1.0, 0.4).is_none());+ }++ #[test]+ fn the_verb_plans_round_other_copper_to_match_a_net() {+ let t = |net: &str, y: f64, x1: f64| json!({"net": net, "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": y}, "end": {"x": x1, "y": y}});+ // A is 30 mm, B 24 mm; another net runs just above A, so the bumps go below it+ let b = json!({"outline": [[0, -20], [40, -20], [40, 20], [0, 20]], "revision": "r3",+ "tracks": [t("A", 0.0, 32.0), t("B", 10.0, 26.0), json!({"net": "X", "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": 0.6}, "end": {"x": 32.0, "y": 0.6}})]});+ let r = length_tune(&json!({"board": b, "args": {"net": "B", "matchTo": "A"}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert!((f(&r["expectedMm"]) - 30.0).abs() < 1e-6, "{r}");+ let a = length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 34.0}}));+ assert_eq!(a["plan"]["side"], "right", "{a}");+ assert_eq!(length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 20.0}}))["changed"], json!(false));+ assert_eq!(length_tune(&json!({"board": b, "args": {"net": "Q", "targetMm": 20.0}}))["errorCode"], "not_routed");+ }+}
engine/src/lib.rs+5−1@@ -19,6 +19,8 @@ pub mod geom; pub mod impedance; pub mod input; pub mod lengths;+pub mod lengthtune;+pub mod pairroute; pub mod placement; pub mod placetools; pub mod route;@@ -31,7 +33,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] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "route_pair", "silk_designators", "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 {@@ -54,12 +56,14 @@ fn answer(check: &str, input: &Value) -> Value { "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "length_tune" => lengthtune::length_tune(input), "net_lengths" => lengths::net_lengths(input), "place_decoupling" => placetools::place_decoupling(input), "plan_fanout" => placetools::plan_fanout(input), "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),+ "route_pair" => pairroute::route_pair(input), "silk_designators" => silkplace::silk_designators(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input),
engine/src/pairroute.rsadded+195@@ -0,0 +1,195 @@+//! A differential pair planned as a pair: one centreline from the midpoint of the source pads through waypoints to the+//! midpoint of the destination pads, each net offset half a pitch (width + gap) to its own side with mitred corners, a+//! short fan-out from each pad, both lengths and the skew, and what the plan would run into. From the Altium bridge's+//! pairroute.rs; the plan is answered as route_net arguments (and the pair's add_rule width rule), for the bridge to+//! apply.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++fn sub(a: P, b: P) -> P { (a.0 - b.0, a.1 - b.1) }+fn norm(a: P) -> P { let l = (a.0 * a.0 + a.1 * a.1).sqrt(); if l > 0.0 { (a.0 / l, a.1 / l) } else { (0.0, 0.0) } }+fn left(d: P) -> P { (-d.1, d.0) }++/// The polyline offset `d` to the left (negative: right), corners mitred. Pure.+pub fn offset(path: &[P], d: f64) -> Vec<P> {+ let n = path.len();+ (0..n).map(|i| {+ let din = if i > 0 { norm(sub(path[i], path[i - 1])) } else { norm(sub(path[1], path[0])) };+ let dout = if i + 1 < n { norm(sub(path[i + 1], path[i])) } else { din };+ let (n1, n2) = (left(din), left(dout));+ let m = norm((n1.0 + n2.0, n1.1 + n2.1));+ let k = (m.0 * n1.0 + m.1 * n1.1).max(0.2); // a hairpin would blow the mitre up: cap it at 5x+ (path[i].0 + m.0 * d / k, path[i].1 + m.1 * d / k)+ }).collect()+}++fn length(p: &[P]) -> f64 { p.windows(2).map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt()).sum() }++/// Both nets' points from pad to pad: the centreline from the pad midpoints through `via`, each net on the side its+/// source pad is on, `fan` mm of fan-out at each end. Pure.+pub fn plan(pos: (P, P), neg: (P, P), via: &[P], width: f64, gap: f64, fan: f64) -> (Vec<P>, Vec<P>) {+ let s = ((pos.0 .0 + neg.0 .0) / 2.0, (pos.0 .1 + neg.0 .1) / 2.0);+ let e = ((pos.1 .0 + neg.1 .0) / 2.0, (pos.1 .1 + neg.1 .1) / 2.0);+ let mut centre = vec![s];+ centre.extend_from_slice(via);+ centre.push(e);+ // pull the ends in by the fan-out so each pad has room to reach its own side+ let d0 = norm(sub(centre[1], centre[0]));+ let k = centre.len();+ let d1 = norm(sub(centre[k - 1], centre[k - 2]));+ centre[0] = (s.0 + d0.0 * fan, s.1 + d0.1 * fan);+ centre[k - 1] = (e.0 - d1.0 * fan, e.1 - d1.1 * fan);+ let half = (width + gap) / 2.0;+ // the positive net goes on the side its source pad is on+ let side = { let v = sub(pos.0, s); let n = left(d0); if v.0 * n.0 + v.1 * n.1 >= 0.0 { 1.0 } else { -1.0 } };+ let mut p = vec![pos.0]; p.extend(offset(¢re, half * side)); p.push(pos.1);+ let mut n = vec![neg.0]; n.extend(offset(¢re, -half * side)); n.push(neg.1);+ (p, n)+}++fn crosses(a: P, b: P, c: P, d: P) -> bool {+ let o = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+ let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+ (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)+}++/// What is wrong with a planned pair before it touches the board: the two nets crossing each other (the pads swap sides+/// between the ends), or a segment closer than the clearance to another net's copper on the layer. `copper` is+/// (what, net, shape). Pure.+pub(crate) fn check(p: &[P], n: &[P], copper: &[(String, String, Shape)], nets: (&str, &str), width: f64, clearance: f64) -> Vec<Value> {+ let mut out = Vec::new();+ for i in 0..p.len().saturating_sub(1) {+ for j in 0..n.len().saturating_sub(1) {+ if crosses(p[i], p[i + 1], n[j], n[j + 1]) {+ out.push(json!({"problem": "pair_crosses", "at": [r3((p[i].0 + p[i + 1].0) / 2.0), r3((p[i].1 + p[i + 1].1) / 2.0)],+ "fix": "the pads are on swapped sides at the two ends: route to a via pair and cross on another layer, or swap the pins"}));+ return out;+ }+ }+ }+ for (path, net) in [(p, nets.0), (n, nets.1)] {+ // the first and last segments leave their own pads: those pads are the net's own, so any copper they touch is checked too+ 'segs: for w in path.windows(2) {+ for (what, on, s) in copper {+ if on.eq_ignore_ascii_case(net) { continue; }+ let d = s.dist(w[0], w[1]);+ if d < width / 2.0 + clearance - 1e-9 {+ out.push(json!({"problem": if what.contains('.') { "over_a_pad" } else { "over_copper" }, "net": net, "with": what, "withNet": on, "gapMm": r3((d - width / 2.0).max(0.0)),+ "fix": "add waypoints (via) that lead the pair out of the part and round other copper"}));+ break 'segs;+ }+ }+ }+ }+ out+}++/// route_pair: input {board, stack?, rules?, args: {pos, neg, from: {pos, neg}, to: {pos, neg}, via?: [[x, y]], layer?,+/// width?, gap?, diffOhms?, fanMm?, clearance?}}. The plan: `routeNet` for each net, the pair's `widthRule`, lengths,+/// skew, and `problems` (the plan answers success: false while there are any).+pub fn route_pair(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let s = |k: &str| args[k].as_str().map(String::from);+ let (Some(pn), Some(nn)) = (s("pos"), s("neg")) else { return crate::input::refuse("bad_args", "pos and neg are the pair's two nets") };+ let pad = |o: &str, k: &str| args[o][k].as_str().map(String::from);+ let (Some(pf), Some(nf), Some(pt), Some(nt)) = (pad("from", "pos"), pad("from", "neg"), pad("to", "pos"), pad("to", "neg")) else {+ return crate::input::refuse("bad_args", "from: {pos: \"U1.3\", neg: \"U1.4\"} and to: {pos, neg} name the pads at each end");+ };+ 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 at = |k: &str| pads.iter().find(|p| key(p).eq_ignore_ascii_case(k)).map(|p| (f(&p["x"]), f(&p["y"])));+ let (Some(a), Some(b), Some(c), Some(d)) = (at(&pf), at(&nf), at(&pt), at(&nt)) else {+ return crate::input::refuse("no_such_pad", &format!("one of {pf}, {nf}, {pt}, {nt} is not a pad on the board"));+ };+ let layer = args["layer"].as_str().unwrap_or("F.Cu").to_string();+ // width and gap: given, or solved from the stack-up for diffOhms on this layer+ let (mut width, mut gap, mut sized) = (args["width"].as_f64(), args["gap"].as_f64(), Value::Null);+ if let (Some(z), Some(stack)) = (args["diffOhms"].as_f64(), crate::input::stack(input)) {+ let geo = crate::impedance::geometry(&stack);+ let names = crate::coppermap::stack_layers(&stack);+ if let Some(i) = names.iter().position(|n| n.0 == layer) {+ if i < geo.len() {+ let rows = crate::impedance::widths(&geo[i..=i], None, Some(z), gap);+ if let (Some(w), Some(g)) = (rows[0]["diffWidth"].as_f64(), rows[0]["diffGap"].as_f64()) { width = width.or(Some(w)); gap = gap.or(Some(g)); sized = rows[0].clone(); }+ }+ }+ }+ let (Some(width), Some(gap)) = (width, gap) else { return crate::input::refuse("bad_args", "give width and gap (mm), or diffOhms with the stack to solve them") };+ let via: Vec<P> = args["via"].as_array().map(|a| a.iter().map(|p| (f(&p[0]), f(&p[1]))).collect()).unwrap_or_default();+ let fan = args["fanMm"].as_f64().unwrap_or(1.0);+ let (pp, np) = plan((a, c), (b, d), &via, width, gap, fan);+ // other nets' copper on the layer: pads by their outline, routed tracks and vias (a hatched pour's net-less pieces+ // re-pour round the pair)+ let rule = crate::fab::routing_clearance(&crate::input::rules(input));+ let clearance = args["clearance"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.15));+ let mut copper: Vec<(String, String, Shape)> = pads.iter().filter(|p| p["layer"] == layer || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0)+ .map(|p| (key(p), p["net"].as_str().unwrap_or("").to_string(), pad_shape(p))).collect();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == json!(layer) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ copper.push(("track".into(), t["net"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+ }+ for v in st["vias"].as_array().into_iter().flatten() {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ copper.push(("via".into(), v["net"].as_str().unwrap_or("").into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+ }+ let problems = check(&pp, &np, &copper, (&pn, &nn), width, clearance);+ let path = |v: &[P], first: &str, last: &str| -> Value {+ let mut out = vec![json!({"pad": first})];+ for p in &v[1..v.len() - 1] { out.push(json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})); }+ out.push(json!({"pad": last}));+ json!([out])+ };+ let (lp, ln) = (length(&pp), length(&np));+ let ok = problems.is_empty();+ json!({"success": ok, "errorCode": if ok { Value::Null } else { json!("pair_plan_blocked") }, "error": if ok { Value::Null } else { json!("the planned pair would run into copper or cross itself") },+ "problems": problems, "widthMm": r3(width), "gapMm": r3(gap), "sizedFor": sized, "layer": layer, "clearanceMm": clearance,+ "routeNet": [{"net": pn, "layer": layer, "width": r3(width), "paths": path(&pp, &pf, &pt)}, {"net": nn, "layer": layer, "width": r3(width), "paths": path(&np, &nf, &nt)}],+ "widthRule": {"kind": "width", "name": format!("Pair {pn} {nn}"), "scope1": format!("InNet('{pn}') Or InNet('{nn}')"), "min": r3(width * 0.9), "preferred": r3(width), "max": r3(width * 1.1)},+ "lengthsMm": {pn.clone(): r3(lp), nn.clone(): r3(ln)}, "skewMm": r3((lp - ln).abs()), "revision": st["revision"],+ "_hint": if ok { "A plan: send each routeNet entry to route_net (with expectedRevision: revision), then widthRule to add_rule so the board's default width rule does not flag the pair. skewMm is the length difference from the fan-outs and corners: net_lengths measures it routed, length_tune evens it." }+ else { "Nothing to apply while problems is not empty: add waypoints (via) that lead the pair out of the parts and round other copper, or change layer where the pair must cross, and plan again." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn offset_keeps_the_pitch_round_a_corner() {+ let l = offset(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 0.5);+ assert!((l[0].1 - 0.5).abs() < 1e-9);+ assert!((l[1].0 - 9.5).abs() < 1e-9 && (l[1].1 - 0.5).abs() < 1e-9);+ assert!((l[2].0 - 9.5).abs() < 1e-9);+ }++ #[test]+ fn plan_puts_each_net_on_its_pads_side_and_check_catches_crossings_and_copper() {+ let (p, n) = plan(((0.0, 0.5), (20.0, 0.5)), ((0.0, -0.5), (20.0, -0.5)), &[], 0.2, 0.15, 1.0);+ assert!(p[1].1 > 0.0 && n[1].1 < 0.0);+ assert!((p[1].1 - n[1].1 - 0.35).abs() < 1e-9);+ assert!((length(&p) - length(&n)).abs() < 1e-9);+ let (x, y) = plan(((0.0, 0.5), (20.0, -0.5)), ((0.0, -0.5), (20.0, 0.5)), &[], 0.2, 0.15, 1.0);+ assert_eq!(check(&x, &y, &[], ("P", "N"), 0.2, 0.15)[0]["problem"], "pair_crosses");+ let pad = pad_shape(&json!({"x": 10.0, "y": 0.175, "sizeX": 0.6, "sizeY": 0.6, "shape": "round"}));+ assert_eq!(check(&p, &n, &[("U1.8".into(), "X".into(), pad)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_a_pad");+ let track = Shape::Cap((10.0, -3.0), (10.0, 3.0), 0.1);+ assert_eq!(check(&p, &n, &[("track".into(), "X".into(), track)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_copper");+ assert!(check(&p, &n, &[], ("P", "N"), 0.2, 0.15).is_empty());+ }++ #[test]+ fn the_verb_answers_route_net_arguments_for_both_nets() {+ let pad = |r: &str, n: &str, x: f64, y: f64, net: &str| json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.3, "sizeY": 0.3, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"});+ let b = json!({"revision": "r7", "pads": [pad("U1", "1", 0.0, 0.5, "USB_P"), pad("U1", "2", 0.0, -0.5, "USB_N"), pad("J1", "1", 20.0, 0.5, "USB_P"), pad("J1", "2", 20.0, -0.5, "USB_N")]});+ let r = route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U1.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["routeNet"][0]["paths"][0][0], json!({"pad": "U1.1"}));+ assert_eq!(r["skewMm"], json!(0.0));+ assert_eq!(r["widthRule"]["preferred"], json!(0.2));+ assert_eq!(route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U9.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}))["errorCode"], "no_such_pad");+ }+}
engine/src/route.rs+1−1@@ -43,7 +43,7 @@ pub(crate) 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 {+ pub(crate) 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) => {
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.17.1",+ "version": "0.18.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+1−1@@ -1,5 +1,5 @@ {- "standard": "0.17.1",+ "standard": "0.18.0", "reference": { "bridge": "altium", "version": "0.2.80"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.17.1",+ "standard": "0.18.0", "reference": { "bridge": "altium", "version": "0.2.80"