← Commit history

standard 0.11.0: engine 0.8.0 adds plan_fanout (KiCad's place_tools) with board-wide conflicts

Colby Knox ·4ad294c79b ·13d ago ·parent fcc00d0
9 files changed +232−7
README.md+3
@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.11.0: the engine (0.8.0) adds `plan_fanout` from the KiCad bridge's place_tools. It also measures each via and stub+against every other net's pad on the board (`conflicts`). On the Bluetooth Sentinel's QFNs it plans U1 and U4 clean and+names U3's three collisions with nearby passives, the same three an independent check finds. Version 0.10.0: the engine (0.7.0) adds `check_clearance` and `place_decoupling` from the KiCad bridge's place_tools. `place_decoupling` answers move_parts arguments and checks them with placement_check. On the Bluetooth Sentinel it places U1's, U3's and U4's caps, each cap with one IC, leaving the crystal caps out, and every cap keeps 0.3 mm from other
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.7.0"+version = "0.8.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.7.0"+version = "0.8.0" 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−1
@@ -20,6 +20,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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. `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 clearance from other nets' copper (pads by their real outline), inside the outline, no via in a pad. `routeNet` is route_net's arguments, unchanged; pours are not obstacles (repour after) | | `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 |@@ -92,7 +93,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.7.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.8.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/lib.rs+2−1
@@ -25,7 +25,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION");  /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -44,6 +44,7 @@ fn answer(check: &str, input: &Value) -> Value {         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "place_decoupling" => placetools::place_decoupling(input),+        "plan_fanout" => placetools::plan_fanout(input),         "placement_check" => placement::placement_check(input),         "plan_route" => route::plan_route(input),         "return_path" => coppermap::return_path(input),
engine/src/placetools.rs+220
@@ -332,6 +332,179 @@ fn validate(st: &Value, input: &Value, m: &BTreeMap<String, Part>, moves: &[(Str     json!({"ok": ov.is_empty() && off.is_empty() && holes.is_empty(), "by": "placement_check on the board with the caps moved", "courtyardOverlaps": ov, "offOrOverEdge": off, "onMountingHoles": holes, "nearEdge": near}) } +fn median(mut v: Vec<f64>) -> Option<f64> {+    if v.is_empty() { return None; }+    v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    Some(v[v.len() / 2])+}++/// The board's copper layers, top to bottom: from the stack's count when given, else the layers its copper uses.+fn copper_layers(st: &Value, input: &Value) -> Vec<String> {+    if let Some(n) = crate::input::stack(input).and_then(|s| s["copperLayers"].as_u64()).filter(|n| *n >= 2) {+        let mut v = vec!["F.Cu".to_string()];+        v.extend((1..n - 1).map(|i| format!("In{i}.Cu")));+        v.push("B.Cu".into());+        return v;+    }+    let mut inner: Vec<u32> = Vec::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(i) = x["layer"].as_str().and_then(|l| l.strip_prefix("In")).and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()) {+                if !inner.contains(&i) { inner.push(i); }+            }+        }+    }+    inner.sort();+    std::iter::once("F.Cu".to_string()).chain(inner.iter().map(|i| format!("In{i}.Cu"))).chain(std::iter::once("B.Cu".to_string())).collect()+}++/// plan_fanout: input {board, stack?, rules?, args: {ref, style?, escapeLayer?, viaMm?, viaDrillMm?, widthMm?, clearanceMm?,+/// pitchMm?, stubMm?, surfaceOuterRing?, viaPerimeter?}}. A BGA's outer ring escapes on the surface and each inner ball+/// takes a dogbone via (or a via in the pad); a perimeter package's pads take an outward stub (with a via at its end for+/// ground or `viaPerimeter`), an exposed pad a via grid. `calls` are route_net arguments, one per net. Every via and+/// stub is measured against other nets' pads on the whole board (`conflicts`).+pub fn plan_fanout(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(r) = args["ref"].as_str().map(str::trim).filter(|s| !s.is_empty()) else { return crate::input::refuse("bad_args", "ref is required: the BGA or QFN/QFP (\"U3\")") };+    let m = parts(&st, input);+    let Some((r, part)) = find(&m, r) else { return crate::input::refuse("no_such_part", &format!("{r} is not a part on this board")) };+    let side = if part.bottom { "B.Cu" } else { "F.Cu" };+    let copper = copper_layers(&st, input);+    let escape = match args["escapeLayer"].as_str() {+        Some(l) if copper.iter().any(|c| c == l) => l.to_string(),+        Some(l) => return json!({"success": false, "errorCode": "no_such_layer", "error": format!("{l} is not a copper layer of this board"), "copperLayers": copper}),+        None => copper.iter().find(|l| l.starts_with("In")).cloned().unwrap_or_else(|| if part.bottom { "F.Cu".into() } else { "B.Cu".into() }),+    };+    let pads: Vec<&Value> = part.pads.iter().filter(|p| f(&p["drill"]) == 0.0).collect();+    if pads.len() < 4 { return crate::input::refuse("not_a_package", &format!("{r} has {} surface pads: fanout is for a BGA or a QFN/QFP", pads.len())); }+    let at = |p: &Value| (f(&p["x"]), f(&p["y"]));+    let net = |p: &Value| p["net"].as_str().unwrap_or("").to_string();+    let name = |p: &Value| format!("{r}.{}", p["name"].as_str().unwrap_or(""));+    let areas: Vec<f64> = pads.iter().map(|p| f(&p["sizeX"]) * f(&p["sizeY"])).collect();+    let med_area = median(areas.clone()).unwrap_or(0.0);+    let (thermal, signal): (Vec<usize>, Vec<usize>) = (0..pads.len()).partition(|i| areas[*i] > 4.0 * med_area);+    let d = |a: P, b: P| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();+    let nn: Vec<f64> = signal.iter().map(|&i| signal.iter().filter(|&&j| j != i).map(|&j| d(at(pads[i]), at(pads[j]))).fold(f64::MAX, f64::min)).collect();+    let pitch = match args["pitchMm"].as_f64() { Some(v) if v > 0.0 => v, _ => match median(nn) { Some(v) => r3(v), None => return crate::input::refuse("not_a_package", "no pad pitch") } };+    let (cx, cy) = (signal.iter().map(|&i| at(pads[i]).0).sum::<f64>() / signal.len() as f64, signal.iter().map(|&i| at(pads[i]).1).sum::<f64>() / signal.len() as f64);+    let bx = signal.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, &i| { let p = at(pads[i]); (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)) });+    let interior = signal.iter().filter(|&&i| { let p = at(pads[i]); (p.0 - bx.0).min(bx.2 - p.0).min(p.1 - bx.1).min(bx.3 - p.1) > pitch * 0.5 }).count();+    let kind = if interior > 0 { "bga" } else { "perimeter" };+    let fine = pitch < 0.8 - 1e-9;+    let via_size = args.get("viaMm").or_else(|| args.get("viaSizeMm")).and_then(|v| v.as_f64()).unwrap_or(if fine { 0.4 } else if pitch < 1.0 - 1e-9 { 0.45 } else { 0.6 });+    let via_drill = args["viaDrillMm"].as_f64().unwrap_or(if via_size <= 0.45 { 0.2 } else { 0.3 });+    if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaMm"); }+    let width = args["widthMm"].as_f64().unwrap_or(if fine { 0.1 } else { 0.15 });+    let rule = crate::fab::min_clearance(&crate::input::rules(input));+    let clearance = args["clearanceMm"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.1));+    let pad_d = median(signal.iter().map(|&i| f(&pads[i]["sizeX"]).max(f(&pads[i]["sizeY"]))).collect()).unwrap_or(0.0);+    let room = r3(pitch / 2f64.sqrt() - pad_d / 2.0 - via_size / 2.0);+    let fits = room + 1e-9 >= clearance;+    let want = args["style"].as_str().unwrap_or("auto");+    let style = match want {+        "auto" => if fits { "dogbone" } else { "via-in-pad" },+        "dogbone" | "via-in-pad" => want,+        o => return crate::input::refuse("bad_args", &format!("style is auto, dogbone or via-in-pad, not {o}")),+    };+    let mut warnings = Vec::new();+    if kind == "bga" && style == "dogbone" && !fits {+        warnings.push(format!("a {via_size} mm dogbone via leaves {room} mm to the balls at {pitch} mm pitch, under the {clearance} mm clearance: use via-in-pad or a smaller via"));+    }+    let via_perimeter = args["viaPerimeter"].as_bool().unwrap_or(false);+    let stub = args["stubMm"].as_f64().unwrap_or(0.4);+    let wp = |p: P, l: &str| json!({"x": r3(p.0), "y": r3(p.1), "layer": l});+    let sign = |v: f64| if v < -1e-9 { -1.0 } else { 1.0 };+    let (mut rows, mut calls): (Vec<Value>, BTreeMap<String, Vec<Value>>) = (Vec::new(), BTreeMap::new());+    // what the plan puts down, to measure: (pad, net, via or stub as a capsule)+    let mut pieces: Vec<(String, String, &'static str, Shape)> = Vec::new();+    type Pieces = Vec<(String, String, &'static str, Shape)>;+    fn via(pieces: &mut Pieces, calls: &mut BTreeMap<String, Vec<Value>>, rows: &mut Vec<Value>, from: String, n: &str, v: P, size: f64, path: Vec<Value>, row: Value) {+        pieces.push((from, n.to_string(), "via", Shape::Cap(v, v, size / 2.0)));+        calls.entry(n.to_string()).or_default().push(json!(path));+        rows.push(row);+    }+    for &i in &signal {+        let p = pads[i];+        let (pa, n) = (at(p), net(p));+        if n.is_empty() { rows.push(json!({"pad": name(p), "net": "", "action": "skip", "why": "no net"})); continue; }+        if kind == "bga" {+            let ring = ((pa.0 - bx.0) / pitch).round().min(((bx.2 - pa.0) / pitch).round()).min(((pa.1 - bx.1) / pitch).round()).min(((bx.3 - pa.1) / pitch).round()) as i64;+            if ring == 0 && args["surfaceOuterRing"].as_bool() != Some(false) {+                let (nv, _) = side_of(bx, pa);+                let end = (pa.0 + nv.0 * pitch, pa.1 + nv.1 * pitch);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "ring": ring, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            } else if style == "dogbone" {+                let v = (pa.0 + sign(pa.0 - cx) * pitch / 2.0, pa.1 + sign(pa.1 - cy) * pitch / 2.0);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, v, width / 2.0)));+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, v, via_size, vec![json!({"pad": name(p)}), wp(v, side), wp(v, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "dogbone", "layer": escape, "via": [r3(v.0), r3(v.1)]}));+            } else {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, pa, via_size, vec![wp(pa, side), wp(pa, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "via-in-pad", "layer": escape, "via": [r3(pa.0), r3(pa.1)], "note": "via-in-pad needs filled and capped vias (IPC-4761 type VII) from the fab"}));+            }+        } else {+            let (nv, edge) = side_of(bx, pa);+            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+            let half = if nv.0 != 0.0 { f(&p["sizeX"]) * c + f(&p["sizeY"]) * s } else { f(&p["sizeX"]) * s + f(&p["sizeY"]) * c } / 2.0;+            let with_via = via_perimeter || is_ground(&n);+            let reach = half + stub + if with_via { via_size / 2.0 } else { 0.0 };+            let end = (pa.0 + nv.0 * reach, pa.1 + nv.1 * reach);+            pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+            if with_via {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, end, via_size, vec![json!({"pad": name(p)}), wp(end, side), wp(end, &escape)],+                    json!({"pad": name(p), "net": n, "edge": edge, "action": "stub+via", "layer": escape, "via": [r3(end.0), r3(end.1)]}));+            } else {+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "edge": edge, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            }+        }+    }+    for &i in &thermal {+        let p = pads[i];+        let n = net(p);+        if n.is_empty() { continue; }+        let (pa, w, h) = (at(p), f(&p["sizeX"]), f(&p["sizeY"]));+        let (w, h) = if (f(&p["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0 { (h, w) } else { (w, h) };+        let vp = (via_size + 0.5).max(1.0);+        let (nx, ny) = ((((w - via_size) / vp).floor() as i64 + 1).max(1), (((h - via_size) / vp).floor() as i64 + 1).max(1));+        let mut grid = Vec::new();+        for a in 0..nx {+            for b in 0..ny {+                let v = (pa.0 + (a as f64 - (nx - 1) as f64 / 2.0) * vp, pa.1 + (b as f64 - (ny - 1) as f64 / 2.0) * vp);+                pieces.push((name(p), n.clone(), "via", Shape::Cap(v, v, via_size / 2.0)));+                calls.entry(n.clone()).or_default().push(json!([wp(v, side), wp(v, &escape)]));+                grid.push(json!([r3(v.0), r3(v.1)]));+            }+        }+        rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp}));+    }+    // each via and stub against every other net's pad on the board (vias on every layer, stubs on the part's side)+    let all: Vec<(Value, Shape)> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().map(|p| { let s = pad_shape(&p); (p, s) }).collect();+    let (mut worst, mut conflicts) = (f64::MAX, Vec::new());+    for (from, n, what, s) in &pieces {+        for (p, ps) in &all {+            if net(p) == *n && !n.is_empty() { continue; }+            if *what == "stub" && !(p["layer"] == side || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0) { continue; }+            let g = s.gap(ps);+            if *what == "via" { worst = worst.min(g); }+            if g < clearance - 1e-9 {+                conflicts.push(json!({"from": from, "what": what, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)}));+            }+        }+    }+    let via_count = pieces.iter().filter(|x| x.2 == "via").count();+    let calls: Vec<Value> = calls.into_iter().map(|(n, paths)| json!({"net": n, "layer": side, "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": paths})).collect();+    json!({"success": true, "ref": r, "package": kind, "padCount": pads.len(), "pitchMm": pitch, "side": side, "escapeLayer": escape, "copperLayers": copper,+        "style": if kind == "bga" { style } else { "stubs" }, "requestedStyle": want, "dogboneRoomMm": room, "dogboneFits": fits,+        "via": {"size": via_size, "drill": via_drill}, "widthMm": width, "clearanceMm": clearance,+        "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": via_count,+        "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "warnings": warnings, "revision": st["revision"],+        "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad on the board (vias on every layer, stubs on the part's side): drop or shorten those before routing. Tracks and pours are not checked: run DRC after. Via-in-pad needs filled and capped vias: tell the user first."})+}+ #[cfg(test)] mod tests {     use super::*;@@ -418,6 +591,53 @@ mod tests {         assert_eq!(check_clearance(&json!({"board": board(), "args": {"mode": "x"}}))["errorCode"], "bad_args");     } +    fn bga(pitch: f64, ball: f64) -> Value {+        // a 4 x 4 BGA at the origin, nets N0..N15, plus a stray resistor pad+        let mut pads = Vec::new();+        for i in 0..4 { for j in 0..4 {+            let (x, y) = ((i as f64 - 1.5) * pitch, (j as f64 - 1.5) * pitch);+            pads.push(json!({"ref": "U1", "name": format!("{}{}", (b'A' + j as u8) as char, i + 1), "x": x, "y": y, "sizeX": ball, "sizeY": ball, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("N{}", i * 4 + j), "shape": "round"}));+        } }+        json!({"revision": "r2", "outline": [[-10, -10], [10, -10], [10, 10], [-10, 10]], "components": [{"ref": "U1", "x": 0, "y": 0, "rotation": 0, "side": "top"}], "pads": pads})+    }++    #[test]+    fn a_bga_escapes_its_outer_ring_and_dogbones_the_rest() {+        let r = plan_fanout(&json!({"board": bga(1.0, 0.45), "stack": {"copperLayers": 4}, "args": {"ref": "U1"}}));+        assert_eq!((r["package"].as_str(), r["style"].as_str(), r["escapeLayer"].as_str()), (Some("bga"), Some("dogbone"), Some("In1.Cu")), "{r}");+        let count = |a: &str| r["pads"].as_array().unwrap().iter().filter(|p| p["action"] == a).count();+        assert_eq!((count("surface"), count("dogbone")), (12, 4));+        // B2 sits at (-0.5, -0.5): its via goes half a pitch out, away from the centre, and changes layer there+        let b2 = r["calls"].as_array().unwrap().iter().find(|c| c["paths"][0][0]["pad"] == "U1.B2").unwrap();+        assert_eq!(b2["paths"][0][1], json!({"x": -1.0, "y": -1.0, "layer": "F.Cu"}));+        assert_eq!(b2["paths"][0][2], json!({"x": -1.0, "y": -1.0, "layer": "In1.Cu"}));+        assert_eq!(r["conflicts"], json!([]), "{r}");+        // at 0.5 mm pitch the dogbone does not fit: via in pad+        let fine = plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1"}}));+        assert_eq!((fine["style"].as_str(), fine["escapeLayer"].as_str()), (Some("via-in-pad"), Some("B.Cu")), "{fine}");+        assert_eq!(plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1", "style": "dogbone"}}))["warnings"].as_array().unwrap().len(), 1);+    }++    #[test]+    fn a_qfn_takes_stubs_a_ground_via_and_a_via_grid_and_names_what_it_would_hit() {+        let mut pads = Vec::new();+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 1), "x": -2.0, "y": 0.75 - k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": if k == 3 { "GND".to_string() } else { format!("S{k}") }, "shape": "rect"})); }+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 5), "x": 2.0, "y": -0.75 + k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("T{k}"), "shape": "rect"})); }+        pads.push(json!({"ref": "U2", "name": "9", "x": 0.0, "y": 0.0, "sizeX": 2.6, "sizeY": 2.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "GND", "shape": "rect"}));+        // R1's pad right where U2.5's stub ends+        pads.push(json!({"ref": "R1", "name": "1", "x": 2.9, "y": -0.75, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "X", "shape": "rect"}));+        let b = json!({"components": [{"ref": "U2", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "R1", "x": 2.9, "y": -0.75, "rotation": 0, "side": "top"}], "pads": pads});+        let r = plan_fanout(&json!({"board": b, "args": {"ref": "U2"}}));+        assert_eq!((r["package"].as_str(), r["pitchMm"].as_f64()), (Some("perimeter"), Some(0.5)), "{r}");+        let action = |pad: &str| r["pads"].as_array().unwrap().iter().find(|p| p["pad"] == pad).unwrap()["action"].clone();+        assert_eq!((action("U2.1"), action("U2.4"), action("U2.9")), (json!("surface"), json!("stub+via"), json!("thermal-via-grid")));+        // U2.1 at x -2 with a 0.8 mm pad: 0.4 + the 0.4 stub reaches x -2.8+        assert_eq!(r["pads"][0]["stubEnd"], json!([-2.8, 0.75]));+        assert_eq!(r["conflicts"].as_array().unwrap().iter().map(|c| c["pad"].as_str().unwrap()).collect::<Vec<_>>(), vec!["R1.1"], "{r}");+        assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "R1"}}))["errorCode"], "not_a_package");+        assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "U2", "escapeLayer": "In3.Cu"}}))["errorCode"], "no_such_layer");+    }+     #[test]     fn pad_copper_gaps_are_exact() {         let a = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect"}));
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.10.0",+  "version": "0.11.0",   "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.10.0",+ "standard": "0.11.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.10.0",+ "standard": "0.11.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"