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.
altium-lane: plan_route takes the board's width and via rules (#21) #4
altium-lane, for #21: plan_route takes the board's width and via rules, so its routes pass the board's DRC. This changes engine/src/features.rs and engine/src/route.rs only.
The problem, found live: Altium's lane board has Altium's default Width rule (10 mil, 0.254 mm). altium_plan_route with apply:true routed N1 at the engine's 0.25 mm default, and routing_drc flagged it: "Actual Width = 9.843mil, Target Width = 10mil". A planned route should pass the board it was planned on.
The fix:
features::width_style(rules)reads the board's preferred track width in mm. It takes the standard form{kind: "width", preferredMm}(orwidthMm), or Altium's Width descriptor ("Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"). It uses the board-wide rule, highest priority first, and its Preferred value, else its Min. This mirrorsvia_style.- plan_route's defaults: the width comes from
widthMm, else the width rule, else 0.25. The via comes fromviaSizeMm/viaDrillMm, else the via rule (the existingvia_style), else 0.6/0.3.planner.widthFromandplanner.viaFromsay which. - The rules are read once, and the clearance uses the same list.
Checked:
- 2 new tests pass: width_style's three forms with the board-wide rule winning, and plan_route following the rules with
widthMmstill winning. That makes 65 unit and 5 conformance tests. - Live on Altium Develop, from a dev build of this engine: N1 on the lane board planned at 0.254 mm with
widthFrom: "the board's width rule". Withapply:trueit routed as one straight top-layer track.routing_drcthen reported only the expected unrouted GND; the Width violation was gone.
Diff Skip to comments
@@ -1,411 +1,441 @@⋯ 187 unchanged lines ⋯ } +/// The board's preferred track width in mm. A standard rule gives it as `{kind: "width", preferredMm}` (or `widthMm`);+/// Altium's Width rule descriptor is read too, e.g. "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)":+/// the board-wide one (scope All), highest priority first, its Preferred else its Min. Pure.+pub fn width_style(rules: &[Value]) -> Option<f64> {+ if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+ .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {+ if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+ }+ let num = |d: &str, key: &str| -> Option<f64> {+ let i = d.find(&format!("({key}="))? + key.len() + 2;+ let s = &d[i..d[i..].find(')')? + i];+ let v: f64 = s.trim_end_matches(char::is_alphabetic).parse().ok()?;+ Some(if s.ends_with("mil") { v * 0.0254 } else { v })+ };+ let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Width")).unwrap_or(false)).collect();+ rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+ let d = rs.first()?["descriptor"].as_str()?;+ num(d, "Preferred").or_else(|| num(d, "Min")).map(|v| (v * 10000.0).round() / 10000.0)+}+ /// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus /// the via's radius, centred. Pure. pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {⋯ 214 unchanged lines ⋯ assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net"); assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found"); }+ #[test]+ fn width_style_reads_the_standard_rule_and_altiums_descriptor() {+ assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+ let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),+ json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];+ assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");+ assert_eq!(width_style(&[json!({"descriptor": "Width Constraint (Min=0.15mm) (Max=1mm) (All)", "scope1": "All"})]), Some(0.15));+ assert_eq!(width_style(&[]), None);+ } }+@@ -1,323 +1,343 @@⋯ 82 unchanged lines ⋯ } /// 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.+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad. The width and via come from+/// the args, else the board's width and via rules (`rules`), else 0.25 mm and 0.6/0.3; `planner` says which. 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"];⋯ 16 unchanged lines ⋯ 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));+ // the board's own width and via rules, so the route passes its DRC (a 0.25 mm default broke a 10 mil Width rule on+ // Altium's lane board, 2026-10-28); args still win+ let rules = crate::input::rules(input);+ let (width_rule, via_rule) = (crate::features::width_style(&rules), crate::features::via_style(&rules));+ let width = num("widthMm", width_rule.unwrap_or(0.25));+ let width_from = if args.get("widthMm").is_some() { "widthMm" } else if width_rule.is_some() { "the board's width rule" } else { "default 0.25" };+ let (via_size, via_drill) = (num("viaSizeMm", via_rule.map(|v| v.0).unwrap_or(0.6)), num("viaDrillMm", via_rule.map(|v| v.1).unwrap_or(0.3)));+ let via_from = if args.get("viaSizeMm").is_some() { "viaSizeMm/viaDrillMm" } else if via_rule.is_some() { "the board's via rule" } else { "default 0.6/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"); }⋯ 1 unchanged line ⋯ 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 rule = crate::fab::min_clearance(&rules); 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);⋯ 130 unchanged lines ⋯ 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()},+ "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "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."}) }⋯ 61 unchanged lines ⋯ assert_eq!(three["found"], json!(true), "{three}"); assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu")); }+ #[test]+ fn the_track_and_via_follow_the_boards_rules() {+ // Altium's lane board: a 10 mil Width rule and a 0.5/0.3 Routing Via rule+ let rules = json!([{"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"},+ {"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"}]);+ let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ assert_eq!((r["routeNet"]["width"].clone(), r["routeNet"]["viaSize"].clone()), (json!(0.254), json!(0.5)), "{r}");+ assert_eq!(r["planner"]["widthFrom"], "the board's width rule");+ let given = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "widthMm": 0.4}}));+ assert_eq!(given["routeNet"]["width"], json!(0.4));+ assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["planner"]["widthFrom"], "default 0.25");+ } }+
Reviews
Comments
Log in to comment.
No comments yet.