← Commit history

standard 0.13.1: engine 0.10.1, place_decoupling leaves room for the fanout

Colby Knox ·8c9e7d8d3d ·13d ago ·parent a466bab
8 files changed +52−8
README.md+2
@@ -158,6 +158,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.13.1: the engine (0.10.1)'s `place_decoupling` keeps each cap clear of the IC's planned fanout (`leaveFanout`), so placing+caps first no longer blocks the escape stubs and vias (altium-lane: 5 conflicts on the Bluetooth Sentinel's U4, now 0). Version 0.13.0: the engine (0.10.0) fixes what fusion-lane found placing plans on a real board (#21, #22): - plan_route checks a through via on every copper layer, not only the planned ones. - It keeps the strictest board-wide clearance rule, not the smallest.
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.10.0"+version = "0.10.1" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.0"+version = "0.10.1" 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
@@ -19,7 +19,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `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. 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 |+| `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 anywhere on the board. 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 |@@ -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.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.1"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/placetools.rs+43−1
@@ -247,6 +247,17 @@ pub fn place_decoupling(input: &Value) -> Value {     // (fusion-lane #22: C32 was planned onto C29, which had no free spot and never moved)     let ring = crate::coppermap::outline_ring(&st);     let on_board = |b: Rect| ring.len() < 3 || [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3)].iter().all(|c| crate::route::inside(*c, &ring));+    // room for the IC's fanout: a cap's pads keep clear of the stubs and vias plan_fanout would give the pins of other+    // nets (altium-lane: caps placed first sat on 5 of U4's escape stubs and vias)+    let (fan, fan_clear) = if args["leaveFanout"].as_bool() == Some(false) { (Vec::new(), 0.0) } else { fanout_room(input, ic_ref) };+    let pads_clear = |cap: &Part, at: P, rot: f64| cap.pads.iter().all(|q| {+        let mut moved = q.clone();+        let c = cap.moved(pad_at(q), at, rot);+        moved["x"] = json!(c.0); moved["y"] = json!(c.1); moved["rotation"] = json!(f(&q["rotation"]) + rot - cap.rot);+        moved.as_object_mut().map(|o| o.remove("copperPoints"));+        let s = pad_shape(&moved);+        fan.iter().all(|(n, piece)| *n == net(q) || s.gap(piece) >= fan_clear - 1e-6)+    });     let mut stay: Vec<String> = Vec::new();     let (mut used, mut rows, mut moves, mut skipped): (Vec<String>, Vec<Value>, Vec<(String, P, f64)>, Vec<Value>);     loop {@@ -295,7 +306,7 @@ pub fn place_decoupling(input: &Value) -> Value {                 let s = if k == 0 { 0.0 } else if k % 2 == 1 { ((k + 1) / 2) as f64 * step } else { -((k / 2) as f64) * step };                 let at = (r3(base.0 + along.0 * s), r3(base.1 + along.1 * s));                 let Some(b) = cap.box_at(at, rot) else { continue };-                if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) { chosen = Some((at, b, s)); break; }+                if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) && pads_clear(cap, at, rot) { chosen = Some((at, b, s)); break; }             }             let Some((at, b, slid)) = chosen else { skipped.push(json!({"cap": cr, "why": "no_free_spot", "pin": format!("{ic_ref}.{pin_name}"), "side": side})); continue };             taken.push(b);@@ -317,6 +328,26 @@ pub fn place_decoupling(input: &Value) -> Value {         "_hint": "Read the plan, then send moveArgs to move_parts. The caps' old tracks stay where they were: route the stubs again (plan_route, route_net) and repour. leftOut lists caps the default left for another IC or a signal net; name them in caps to place them here."}) } +/// The copper plan_fanout would put down for an IC's pins, by net, and the clearance it keeps; nothing when the IC is no+/// package fanout plans (fewer than four surface pads).+fn fanout_room(input: &Value, ic: &str) -> (Vec<(String, Shape)>, f64) {+    let mut q = input.clone();+    q["args"] = json!({"ref": ic});+    let r = plan_fanout(&q);+    if r["success"] != json!(true) { return (Vec::new(), 0.0); }+    let st = crate::input::board(input).unwrap_or_default();+    let at = |name: &str| st["pads"].as_array().into_iter().flatten().find(|p| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")) == name).map(|p| (f(&p["x"]), f(&p["y"])));+    let (w, v) = (f(&r["widthMm"]) / 2.0, f(&r["via"]["size"]) / 2.0);+    let mut out = Vec::new();+    for row in r["pads"].as_array().into_iter().flatten() {+        let (Some(pa), Some(n)) = (row["pad"].as_str().and_then(at), row["net"].as_str()) else { continue };+        let pt = |x: &Value| (f(&x[0]), f(&x[1]));+        if row["stubEnd"].is_array() { out.push((n.to_string(), Shape::Cap(pa, pt(&row["stubEnd"]), w))); }+        if row["via"].is_array() { let e = pt(&row["via"]); out.push((n.to_string(), Shape::Cap(pa, e, w))); out.push((n.to_string(), Shape::Cap(e, e, v))); }+    }+    (out, f(&r["clearanceMm"]))+}+ /// placement_check on the board with the caps moved, keeping what concerns them. fn validate(st: &Value, input: &Value, m: &BTreeMap<String, Part>, moves: &[(String, P, f64)]) -> Value {     if moves.is_empty() { return json!({"ok": false, "reason": "nothing to place"}); }@@ -565,6 +596,17 @@ mod tests {         assert_eq!(r["icPowerNets"], json!(["VDD"]));     } +    #[test]+    fn a_cap_leaves_room_for_the_ics_fanout() {+        // a second ground pin, U1.5, just under the VDD pin: its fanout via lands where C1 would go (altium-lane)+        let mut b = board();+        b["pads"].as_array_mut().unwrap().push(pad("U1", "5", 2.0, 0.4, "GND"));+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+        assert!(f(&r["placements"][0]["slidMm"]).abs() > 0.0, "{r}");+        let close = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"], "leaveFanout": false}}));+        assert_eq!(close["placements"][0]["slidMm"], json!(0.0), "{close}");+    }+     #[test]     fn a_cap_that_cannot_move_keeps_its_spot() {         // C2 sits where C1 would go, and cannot be placed (its net is none of U1's): C1 slides past it (fusion-lane #22)
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.13.0",+  "version": "0.13.1",   "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.0",+ "standard": "0.13.1",  "reference": {   "bridge": "altium",   "version": "0.2.79"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.13.0",+ "standard": "0.13.1",  "reference": {   "bridge": "altium",   "version": "0.2.79"