← Commit history

standard 0.13.3: engine 0.10.3 (avoidPours by net, pourCost); fusion 1.13.6, kicad 1.0.76

Colby Knox ·d991b05865 ·13d ago ·parent cf66dea
9 files changed +73−31
README.md+5−3
@@ -61,11 +61,11 @@ verb by verb: [docs/coverage.md](docs/coverage.md). | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---| | **core** | 93 | 93 | 90 | 79 |-| **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 35 | 18 |+| **native** | 46 | 46 | 43 | 32 |+| **checks** | 37 | 33 | 35 | 19 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 163 |+| standard (all but platform) | 236 | 232 | 204 | 167 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +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): - It skips pins already routed. - It measures every via and stub against other nets' tracks, arcs and vias, not only pads.
docs/coverage.md+8−8
@@ -1,17 +1,17 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.75, Fusion 360 bridge 1.13.5. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.76, Fusion 360 bridge 1.13.6. 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.  | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---| | **core** | 93 | 93 | 90 | 79 |-| **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 35 | 18 |+| **native** | 46 | 46 | 43 | 32 |+| **checks** | 37 | 33 | 35 | 19 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 163 |+| standard (all but platform) | 236 | 232 | 204 | 167 |  ## core @@ -119,13 +119,13 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve  | Verb | What it does | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| `add_rule` | Add one SCOPED design rule that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length... | `altium_add_rule` | `kicad_add_rule` |  |+| `add_rule` | Add one SCOPED design rule that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length... | `altium_add_rule` | `kicad_add_rule` | `fusion_add_rule` | | `annotate` | Number every '?' designator (R?, C?, U?) across the project's sheets: the lowest free number per prefix, top-left first on each sheet. dryRun shows the plan. | `altium_annotate` | `kicad_annotate` | `fusion_annotate` | | `apply_pours` | Add solid polygon pours from outlines and pour them, each with its own connect style (solid joints or thermal spokes), plus pour keepouts (polygon cutouts);... | `altium_apply_pours` | `kicad_add_zone / kicad_apply_zone_plan` | `fusion_change_pours` (partial) | | `autoroute` | Altium's own autorouter (Situs) on the current board: picks a strategy, keeps existing routing locked by default, routes, and answers the unconnected count... | `altium_autoroute` | `kicad_autoroute` | `fusion_autoroute` | | `build_symbol_modes` | Build ONE SchLib component with N display modes (Normal + Alternate 1..N-1) from a symbol_modes spec file on this machine, and return a proof frame. | `altium_build_symbol_modes` |  |  | | `cleanup_libs` | Remove the factory-default stray every fresh library carries: Component_1 from the current SchLib and an empty (no pads) PCBComponent_1 from the current PcbLib. | `altium_cleanup_libs` |  |  |-| `compile` | Compile a project and return Altium's Messages panel (ERC + connectivity) with counts by class. With no path it compiles Altium's bundled example project. | `altium_compile` | `kicad_compile` |  |+| `compile` | Compile a project and return Altium's Messages panel (ERC + connectivity) with counts by class. With no path it compiles Altium's bundled example project. | `altium_compile` | `kicad_compile` | `fusion_run_erc` (partial) | | `component_classes` | List, make or remove component classes on the board, each optionally with a room: a placement area that keeps its parts together (fitted around the parts or... | `altium_component_classes` | `kicad_component_classes` |  | | `create_part_project` *proposed* | Generate a small real project (schematic and board) around one library part, so the part can be shown and tested in every view: schematic, 2D board and 3D... | `altium_create_part_project` | `kicad_make_part_project` (partial) | `fusion_make_test_project` (partial) | | `diff_pairs` | Differential pairs on the current board: list them, define one from two nets (replacing a pair of that name), or remove one. Altium's differential-pair... | `altium_diff_pairs` | `kicad_diff_pairs` (partial) |  |@@ -164,7 +164,7 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve | `symbol_mode` | Show one display mode (0 = Normal, 1.. = alternates) of the CURRENT SchLib component in the editor and capture a frame. Drives the editor's own mode toggle,... | `altium_symbol_mode` |  |  | | `teardrops` | Add (or remove) teardrops with Altium's own Tools > Teardrops: on vias and through-hole pads, SMD pads, track width changes and T-junctions, for the whole... | `altium_teardrops` | `kicad_teardrops` (partial) |  | | `uninstall_library` | Remove libraries from Altium's Installed Libraries: the inverse of install_library / install_library_bundle. Library files are never touched. | `altium_uninstall_library` | `kicad_uninstall_library` |  |-| `update_pcb` | Design > Update PCB Document from the schematic (the engineering change order): parts added, removed or renamed, nets and footprints updated on the board.... | `altium_update_pcb` | `kicad_update_pcb_from_schematic` (partial) |  |+| `update_pcb` | Design > Update PCB Document from the schematic (the engineering change order): parts added, removed or renamed, nets and footprints updated on the board.... | `altium_update_pcb` | `kicad_update_pcb_from_schematic` (partial) | `fusion_cross_check` (partial) |  ## checks @@ -185,7 +185,7 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `copper_balance` | How much of each copper layer is copper (tracks, pads, vias and what Altium actually poured, inside the outline; a plane counts full), and how far apart... | `altium_copper_balance` | `kicad_copper_balance` | `fusion_copper_balance` | | `cross_check` | Compare one schematic sheet's designators with a board's. Altium holds ONE current document, so the verb activates the board, reads it, then activates the... | `altium_cross_check` | `kicad_analyze_connections` (partial) | `fusion_cross_check` | | `current_capacity` | Will this trace carry the current? For each power net and its current: the narrowest track on every layer it is routed on against the width IPC-2221 needs... | `altium_current_capacity` | `kicad_current_capacity` | `fusion_current_capacity` |-| `estimate_cost` *proposed* | The board's numbers a fab quote needs (size, layers, thickness, smallest track, gap and drill, holes, SMD and through-hole parts and joints, unique parts)... |  | `kicad_estimate_cost` |  |+| `estimate_cost` *proposed* | The board's numbers a fab quote needs (size, layers, thickness, smallest track, gap and drill, holes, SMD and through-hole parts and joints, unique parts)... |  | `kicad_estimate_cost` | `fusion_estimate_cost` | | `fab_check` | Before ordering: the board's fab-relevant minimums measured from the live board (narrowest track, smallest via drill, diameter and annular ring, smallest... | `altium_fab_check` | `kicad_fab_check` | `fusion_fab_check` | | `find_space` | Free spots on the board for a part near another (clear of every other part on that side, of that side's tracks and vias, and inside the outline), nearest... | `altium_find_space` | `kicad_find_space` |  | | `footprint_from_land_pattern` | Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an... | `altium_footprint_from_land_pattern` | `kicad_footprint_from_land_pattern` |  |
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.10.2"+version = "0.10.3" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.2"+version = "0.10.3" 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 |+| `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 | | `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.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.3"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/route.rs+31−5
@@ -146,7 +146,16 @@ pub fn plan_route(input: &Value) -> Value {     for l in &layers { if !copper.contains(l) { copper.push(l.clone()); } }     // other nets' pours: not obstacles (they re-pour round the route), but a route across one can cut it in two, so the     // reply names each one it crosses, and avoidPours keeps the route out of them-    let avoid_pours = args["avoidPours"].as_bool().unwrap_or(false);+    // avoidPours: true keeps out of every other net's pour, a list of nets out of theirs only; pourCost makes each mm run+    // inside a pour cost 1 + pourCost mm instead, a preference rather than a wall (fusion-lane #21: on BQ25792 almost+    // every spot is under some pour, and a hard block found no path)+    let avoid_nets: Option<Vec<String>> = match &args["avoidPours"] {+        Value::Bool(true) => Some(Vec::new()),+        Value::Array(a) => Some(a.iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect()),+        _ => None,+    };+    let avoids = |n: &str| avoid_nets.as_ref().map(|v| v.is_empty() || v.contains(&n.to_uppercase())).unwrap_or(false);+    let pour_cost = num("pourCost", 0.0).max(0.0);     let pours: Vec<(String, String, Shape)> = st["polygons"].as_array().cloned().unwrap_or_default().iter()         .filter(|p| p["net"].as_str().map(|n| !n.is_empty() && !n.eq_ignore_ascii_case(net)).unwrap_or(false))         .filter_map(|p| {@@ -221,7 +230,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])-                && !(avoid_pours && pours.iter().any(|(_, l, s)| *l == layers[u.2] && s.dist(a, b) < width / 2.0))+                && !pours.iter().any(|(n, l, s)| avoids(n) && *l == layers[u.2] && s.dist(a, b) < width / 2.0)         };         cache.insert(k, ok);         ok@@ -247,7 +256,11 @@ pub fn plan_route(input: &Value) -> Value {         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 step = if v.2 != u.2 { via_cost } else {+                let (a, b) = ((xs[u.0], ys[u.1]), (xs[v.0], ys[v.1]));+                let len = (b.0 - a.0).abs() + (b.1 - a.1).abs();+                if pour_cost > 0.0 && pours.iter().any(|(_, l, s)| *l == layers[u.2] && s.dist(a, b) < width / 2.0) { len * (1.0 + pour_cost) } else { len }+            };             let ng = g + step;             if ng >= cost.get(&v).copied().unwrap_or(f64::MAX) - 1e-9 { continue; }             cost.insert(v, ng);@@ -292,9 +305,9 @@ pub fn plan_route(input: &Value) -> Value {     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()},-        "crossesPours": crosses, "avoidPours": avoid_pours, "revision": revision,+        "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 with avoidPours: true to keep out of them. A layer change is two waypoints at the same point, where route_net places a via." }})+                 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." }}) }  #[cfg(test)]@@ -403,5 +416,18 @@ mod tests {         assert_eq!((r["vias"].as_u64(), r["crossesPours"].clone()), (Some(0), json!([{"net": "GND", "layer": "F.Cu"}])), "{r}");         let around = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}));         assert_eq!((around["vias"].as_u64(), around["crossesPours"].clone()), (Some(2), json!([])), "{around}");+        // 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)+        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));+        // 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}");     } }
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.13.2",+  "version": "0.13.3",   "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+21−7
@@ -1,13 +1,13 @@ {- "standard": "0.13.2",+ "standard": "0.13.3",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },  "proposed": 54,  "compared": {-  "kicad": "1.0.75",-  "fusion360": "1.13.5"+  "kicad": "1.0.76",+  "fusion360": "1.13.6"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",@@ -1579,7 +1579,10 @@     "verb": "kicad_add_rule",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_add_rule",+    "match": "exact"+   }   },   {    "name": "annotate",@@ -1666,7 +1669,11 @@     "verb": "kicad_compile",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_run_erc",+    "match": "partial",+    "note": "Fusion's ERC on the open design's schematic (messages with class, source, sheet; byClass): there is no project file to compile, so projectPath does not apply"+   }   },   {    "name": "component_classes",@@ -2299,7 +2306,11 @@     "verb": "kicad_update_pcb_from_schematic",     "match": "partial"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_cross_check",+    "match": "partial",+    "note": "Fusion forward-annotates the board from the schematic as you edit, so there is no change order to execute: the preview is the cross check (designators on the sheet and not the board, and the reverse); a broken forward/back annotation is reported, not repaired"+   }   },   {    "name": "assembly_check",@@ -2511,7 +2522,10 @@     "verb": "kicad_estimate_cost",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_estimate_cost",+    "match": "exact"+   }   },   {    "name": "fab_check",
verbs/standard.json+3−3
@@ -1,13 +1,13 @@ {- "standard": "0.13.2",+ "standard": "0.13.3",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },  "proposed": 54,  "compared": {-  "kicad": "1.0.75",-  "fusion360": "1.13.5"+  "kicad": "1.0.76",+  "fusion360": "1.13.6"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",