← Commit history

standard 0.15.2: engine 0.12.2, assembly_check one-land pads and pad-less parts (#24), plan_route withoutAvoid (#21)

Colby Knox ·275a6ef246 ·12d ago ·parent 207f176
10 files changed +47−14
README.md+4
@@ -158,6 +158,10 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.15.2: the engine (0.12.2) fixes fusion-lane's findings:+- `assembly_check` counts pads sharing one centre as one land, and leaves out parts with no pads (#24).+- A `plan_route` that finds no path while avoiding pours says whether a path exists through them (`withoutAvoid`, #21).+ Version 0.15.1: the engine (0.12.1)'s `plan_route` explains a no-path answer: its planner block, and the avoided pours at each end pad with whether they are exempt (fusion-lane #21). Version 0.15.0: the engine (0.12.0) adds `assembly_check` (matching the Altium bridge's answers on its test boards) and
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.12.1"+version = "0.12.2" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.12.1"+version = "0.12.2" 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−3
@@ -11,7 +11,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives.  | Check | Reads | What it answers | |---|---|---|-| `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs |+| `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) | | `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) |@@ -24,7 +24,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `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) | | `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 | @@ -98,7 +98,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.12.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.12.2"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/assembly.rs+16−3
@@ -22,7 +22,9 @@ pub fn facts(st: &Value) -> Value {     let mut chip: Option<(f64, String)> = None;     for (r, ps) in &by {         let mut d = f64::MAX;-        for i in 0..ps.len() { for j in i + 1..ps.len() { d = d.min(((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt()); } }+        // pads with one centre are one land (a MOSFET's drain pads drawn as one polygon, fusion-lane #24), not a 0 mm pitch+        for i in 0..ps.len() { for j in i + 1..ps.len() { let e = ((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt(); if e > 0.01 { d = d.min(e); } } }+        if d == f64::MAX { continue; }         if ps.len() >= 3 && pitch.as_ref().map(|p| d < p.0).unwrap_or(true) { pitch = Some((d, r.clone())); }         if ps.len() == 2 && chip.as_ref().map(|p| d < p.0).unwrap_or(true) { chip = Some((d, r.clone())); }     }@@ -31,10 +33,12 @@ pub fn facts(st: &Value) -> Value {     let fid = |s: &str| s.to_ascii_uppercase().starts_with("FID");     let fiducials = comps.iter().filter(|c| c["ref"].as_str().map(fid).unwrap_or(false) || named(c, &["FIDUCIAL"])).count()         + pads.iter().filter(|p| p["ref"].as_str().map(|r| r.is_empty()).unwrap_or(true) && p["name"].as_str().map(fid).unwrap_or(false)).count();-    let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom").map(|c| c["ref"].clone()).collect();+    // a part is a component with pads: a logo or an icon has none and is never placed (fusion-lane #24)+    let placed = |c: &Value| pads.iter().any(|p| p["ref"] == c["ref"]);+    let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom" && placed(c)).map(|c| c["ref"].clone()).collect();     let tht = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| !r.is_empty()).unwrap_or(false))         .filter_map(|p| p["ref"].as_str().map(String::from)).collect::<std::collections::BTreeSet<_>>();-    json!({"parts": comps.len(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),+    json!({"parts": comps.iter().filter(|c| placed(c)).count(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),            "smallestChip": chip.map(|(d, r)| json!({"padCentresMm": r3(d), "ref": r})), "tinyFootprints": tiny, "fiducials": fiducials,            "throughHoleParts": tht.into_iter().collect::<Vec<_>>()}) }@@ -118,6 +122,15 @@ mod tests {         assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));         assert_eq!(fa["smallestChip"], json!({"padCentresMm": 1.0, "ref": "C1"}));         assert_eq!(fa["bottomSideParts"], json!(["R9"]));+        // fusion-lane #24: pads sharing one centre are one land, and a pad-less icon on the bottom is no part+        let mut b = board();+        b["components"].as_array_mut().unwrap().push(json!({"ref": "Q1", "side": "top", "pattern": "PowerPAK"}));+        b["components"].as_array_mut().unwrap().push(json!({"ref": "LOGO1", "side": "bottom", "pattern": "ICON"}));+        for k in 5..=8 { b["pads"].as_array_mut().unwrap().push(json!({"ref": "Q1", "name": k.to_string(), "x": 20.0, "y": 0.0, "drill": 0.0})); }+        b["pads"].as_array_mut().unwrap().push(json!({"ref": "Q1", "name": "1", "x": 20.0, "y": 1.27, "drill": 0.0}));+        let fa = facts(&b);+        assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));+        assert_eq!((fa["bottomSideParts"].clone(), fa["parts"].clone()), (json!(["R9"]), json!(4)));     }      #[test]
engine/src/route.rs+17−2
@@ -290,8 +290,19 @@ pub fn plan_route(input: &Value) -> Value {             json!({"pad": key(p), "avoidedPoursHere": touching})         }).collect();         let walled = near_ends.iter().any(|e| e["avoidedPoursHere"].as_array().map(|a| a.iter().any(|x| x["exempt"] == json!(false))).unwrap_or(false));-        let why = if avoid_nets.is_some() && walled { "no legal path: an avoided pour touches an end pad without holding it (avoidedPoursHere), so the route cannot leave the pad without entering it. Try pourCost instead of avoidPours, or drop that net from avoidPours" } else { why };-        return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision, "ends": near_ends,+        // when keeping out of the pours is what fails, say so: plan again through them (fusion-lane #21: VBAT's pour wraps+        // SW1.2 in a notch it never touches)+        let mut through = Value::Null;+        if avoid_nets.is_some() && seen.len() < max_nodes {+            let mut again = input.clone();+            again["args"].as_object_mut().map(|o| o.remove("avoidPours"));+            let r = plan_route(&again);+            if r["found"] == json!(true) { through = json!({"found": true, "lengthMm": r["lengthMm"], "vias": r["vias"], "crossesPours": r["crossesPours"]}); }+        }+        let why = if avoid_nets.is_some() && walled { "no legal path: an avoided pour touches an end pad without holding it (avoidedPoursHere), so the route cannot leave the pad without entering it. Try pourCost instead of avoidPours, or drop that net from avoidPours".to_string() }+            else if !through.is_null() { format!("no path keeps out of the avoided pours: every way between the pads crosses one (a path through them exists: withoutAvoid). Use pourCost to go round them where it can, or drop a net from avoidPours") }+            else { why.to_string() };+        return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision, "ends": near_ends, "withoutAvoid": through,             "planner": {"algorithm": "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, "expandedNodes": seen.len()}});     };@@ -487,6 +498,10 @@ mod tests {         assert_eq!(walled["ends"][0]["avoidedPoursHere"][0], json!({"net": "GND", "layer": "F.Cu", "exempt": false}), "{walled}");         assert!(walled["reason"].as_str().unwrap().contains("avoidedPoursHere"));         assert_eq!(walled["planner"]["avoidPours"], json!(["GND"]));+        // a wall it never touches: the reply says a path exists through the pours+        let wall_only = plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}));+        assert_eq!(wall_only["withoutAvoid"]["found"], json!(true), "{wall_only}");+        assert!(wall_only["reason"].as_str().unwrap().contains("every way between the pads crosses one"));         // 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]]}]);
engine/tests/fixtures.rs+2−1
@@ -67,6 +67,7 @@ fn placement_check_matches_altium() {  #[test] fn assembly_check_matches_altium() {-    same("assembly_check", &["service", "fab", "board", "economic.ok", "standard.ok", "economic.rules.0.ok", "economic.rules.1.ok", "economic.rules.2.ok",+    // board.parts: the engine counts only components with pads (a logo is no part, fusion-lane #24), Altium all of them+    same("assembly_check", &["service", "fab", "board.bottomSideParts", "board.finestPitch", "board.smallestChip", "board.tinyFootprints", "board.fiducials", "board.throughHoleParts", "economic.ok", "standard.ok", "economic.rules.0.ok", "economic.rules.1.ok", "economic.rules.2.ok",                              "economic.rules.3.ok", "economic.rules.4.ok", "economic.rules.5.ok", "standard.rules.0.ok", "standard.rules.3.ok"]); }
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.15.1",+  "version": "0.15.2",   "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.15.1",+ "standard": "0.15.2",  "reference": {   "bridge": "altium",   "version": "0.2.80"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.15.1",+ "standard": "0.15.2",  "reference": {   "bridge": "altium",   "version": "0.2.80"