← Commit history

standard 0.13.4: engine 0.10.4, plan_route held pours exempt, pour settings in planner, 45-degree smoothing

Colby Knox ·bca24e18b1 ·12d ago ·parent 5bc18be
8 files changed +88−17
README.md+7
@@ -158,6 +158,13 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.13.4: the engine (0.10.4)'s `plan_route` (fusion-lane #21):+- A pour holding the route's own end pad is exempt from `avoidPours`.+- `planner` echoes the pour settings.+- Grid staircases are smoothed into straight and 45-degree segments.++On the Bluetooth Sentinel, IO_Red_LED6 goes from 42 corners to 19 and 59.5 mm to 57.1 mm, still at the board's 0.1 mm+(checked independently). Version 0.13.3: the engine (0.10.3)'s `plan_route` takes `avoidPours` as a list of nets, and a `pourCost` that prefers going round pours without forbidding them (fusion-lane #21: on BQ25792 a hard block found no path). Version 0.13.2: the engine (0.10.2)'s `plan_fanout` works on a routed board (altium-lane #22):
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.10.3"+version = "0.10.4" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.3"+version = "0.10.4" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/README.md+2−2
@@ -22,7 +22,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, 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 |+| `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`). The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -95,7 +95,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.10.3"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.4"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/route.rs+74−10
@@ -162,6 +162,15 @@ pub fn plan_route(input: &Value) -> Value {             let ring: Vec<P> = p["points"].as_array()?.iter().map(|q| (f(&q[0]), f(&q[1]))).collect();             (ring.len() >= 3).then(|| (p["net"].as_str().unwrap_or("").to_string(), p["layer"].as_str().unwrap_or("").to_string(), Shape::Poly(ring)))         }).collect();+    // a pour that holds the route's own end pad cannot be kept out of (fusion-lane #21: SW1.2 sits inside VBAT's F.Cu+    // pour, so avoidPours: ["VBAT"] found no path): it is exempt, and the reply names it+    let held: Vec<usize> = (0..pours.len()).filter(|&i| {+        let (_, l, s) = &pours[i];+        let Shape::Poly(ring) = s else { return false };+        ends.iter().any(|p| pad_layers(p, &copper).contains(l) && inside((f(&p["x"]), f(&p["y"])), ring))+    }).collect();+    let blocks = |i: usize| avoids(&pours[i].0) && !held.contains(&i);+    let exempt: Vec<Value> = held.iter().filter(|&&i| avoids(&pours[i].0)).map(|&i| json!({"net": pours[i].0, "layer": pours[i].1, "why": "it holds an end pad of the route"})).collect();     // 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)@@ -230,7 +239,7 @@ pub fn plan_route(input: &Value) -> Value {             edge_ok(a, a, r) && copper.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])-                && !pours.iter().any(|(n, l, s)| avoids(n) && *l == layers[u.2] && s.dist(a, b) < width / 2.0)+                && !(0..pours.len()).any(|i| blocks(i) && pours[i].1 == layers[u.2] && pours[i].2.dist(a, b) < width / 2.0)         };         cache.insert(k, ok);         ok@@ -276,14 +285,39 @@ pub fn plan_route(input: &Value) -> Value {     let mut path = vec![goal_node];     while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }     path.reverse();+    // smooth the grid path: from each point, the farthest later point on the same layer that a straight or 45-degree+    // segment reaches with the same clearance, inside the outline and into no pour the grid path kept out of. Staircases+    // become diagonals (fusion-lane #21: Fusion's DRC flags every vertex of a staircase). smooth: false keeps the grid path.+    let at = |u: (usize, usize, usize)| (xs[u.0], ys[u.1]);+    let pours_hit = |a: P, b: P, l: usize| -> Vec<usize> { (0..pours.len()).filter(|&i| pours[i].1 == layers[l] && pours[i].2.dist(a, b) < width / 2.0).collect() };+    let octilinear = |a: P, b: P| { let (dx, dy) = ((b.0 - a.0).abs(), (b.1 - a.1).abs()); dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9 };+    let smooth = args["smooth"].as_bool().unwrap_or(true);+    let mut pts = vec![path[0]];+    let mut i = 0;+    while i + 1 < path.len() {+        if path[i + 1].2 != path[i].2 || !smooth { pts.push(path[i + 1]); i += 1; continue; }+        let (mut best, mut j) = (i + 1, i + 2);+        let mut grid_pours: Vec<usize> = pours_hit(at(path[i]), at(path[i + 1]), path[i].2);+        while j < path.len() && path[j].2 == path[i].2 {+            for p in pours_hit(at(path[j - 1]), at(path[j]), path[i].2) { if !grid_pours.contains(&p) { grid_pours.push(p); } }+            let (a, b) = (at(path[i]), at(path[j]));+            if octilinear(a, b) && edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[path[i].2])+                && pours_hit(a, b, path[i].2).iter().all(|p| grid_pours.contains(p) && !blocks(*p)) { best = j; }+            j += 1;+        }+        pts.push(path[best]);+        i = best;+    }     // keep the corners and the layer changes-    let mut keep = vec![path[0]];-    for w in path.windows(3) {+    let mut keep = vec![pts[0]];+    for w in pts.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; }+        let (pa, pb, pc) = (at(a), at(b), at(c));+        let straight = ((pb.0 - pa.0) * (pc.1 - pb.1) - (pb.1 - pa.1) * (pc.0 - pb.0)).abs() < 1e-9 && (pb.0 - pa.0) * (pc.0 - pb.0) + (pb.1 - pa.1) * (pc.1 - pb.1) >= 0.0;+        if a.2 == b.2 && b.2 == c.2 && straight { continue; }         keep.push(b);     }-    if path.len() > 1 { keep.push(*path.last().unwrap()); }+    if pts.len() > 1 { keep.push(*pts.last().unwrap()); }     let mut way = vec![json!({"pad": key(&ends[0])})];     let mut vias = Vec::new();     let mut length = 0.0;@@ -304,7 +338,8 @@ pub fn plan_route(input: &Value) -> Value {     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*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+        "planner": {"algorithm": if smooth { "bounded orthogonal A*, smoothed to straight and 45-degree segments" } else { "bounded orthogonal A*" }, "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge,+            "avoidPours": args["avoidPours"].clone(), "avoidExempt": exempt, "pourCost": pour_cost, "smooth": smooth, "expandedNodes": seen.len()},         "crossesPours": crosses, "avoidPours": args["avoidPours"].clone(), "pourCost": pour_cost, "revision": revision,         "_hint": if crosses.is_empty() { "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." }                  else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again keeping out of them: avoidPours: true (or a list of the nets whose pours to keep out of), or pourCost: 2 to prefer going round them where a way exists. A layer change is two waypoints at the same point, where route_net places a via." }})@@ -419,15 +454,44 @@ mod tests {         // by net: avoiding another net's pours only leaves this one crossable         assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["VBAT"]}}))["vias"], json!(0));         assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["gnd"]}}))["vias"], json!(2));-        // a pour over the whole board: a hard block finds no path, a cost still routes (across it)+        // a wall of pours across both layers: a hard block finds no path, a cost still routes (across it)+        let mut wall = board(json!([]));+        wall["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}]);+        assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));+        assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+        // a pour that holds the route's own end pad cannot be kept out of: it is exempt and named (fusion-lane #21)         let mut all = board(json!([]));-        all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);-        assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));-        assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+        all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);+        let held = plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["GND"]}}));+        assert_eq!(held["found"], json!(true), "{held}");+        assert_eq!(held["planner"]["avoidExempt"][0]["net"], "GND");+        assert_eq!(held["planner"]["pourCost"], json!(0.0));         // and with the cost a short way round a narrow pour beats crossing it         let mut strip = board(json!([]));         strip["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[9.0, 2.0], [11.0, 2.0], [11.0, 10.0], [9.0, 10.0]]}]);         let soft = plan_route(&json!({"board": strip, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 10.0, "allowVias": false}}));         assert_eq!(soft["crossesPours"], json!([]), "{soft}");     }+    #[test]+    fn a_staircase_becomes_one_45_degree_segment() {+        // U1.1 at (2, 5) to a pad at (8, 9): the grid path is a staircase of corners; smoothed, the plan is straight and+        // 45-degree runs only, the diagonal clear of everything+        let mut b = board(json!([]));+        b["pads"][1]["x"] = json!(8.0); b["pads"][1]["y"] = json!(9.0);+        let r = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        let way = r["routeNet"]["paths"][0].as_array().unwrap().clone();+        let mut pts = vec![(2.0, 5.0)];+        pts.extend(way[1..way.len() - 1].iter().map(|w| (f(&w["x"]), f(&w["y"]))));+        pts.push((8.0, 9.0));+        assert!(pts.len() <= 3, "at most one corner: {r}");+        for w in pts.windows(2) {+            let (dx, dy) = ((w[1].0 - w[0].0).abs(), (w[1].1 - w[0].1).abs());+            assert!(dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9, "{w:?}");+        }+        assert!(pts.windows(2).any(|w| (w[1].0 - w[0].0).abs() > 1e-9 && (w[1].1 - w[0].1).abs() > 1e-9), "one diagonal: {r}");+        // shorter than the 10 mm staircase: 4 * sqrt(2) + 2+        assert!((f(&r["lengthMm"]) - (4.0 * 2f64.sqrt() + 2.0)).abs() < 1e-3, "{r}");+        let grid = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "smooth": false}}));+        assert!((f(&grid["lengthMm"]) - 10.0).abs() < 1e-9);+    } }
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.13.3",+  "version": "0.13.4",   "type": "skill",   "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool",   "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.13.3",+ "standard": "0.13.4",  "reference": {   "bridge": "altium",   "version": "0.2.79"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.13.3",+ "standard": "0.13.4",  "reference": {   "bridge": "altium",   "version": "0.2.79"