← Commit history

standard 0.6.0, engine 0.3.0: thermal_vias and via_fence move into the engine from the Altium bridge's features.rs (plans as place_vias arguments; via size from args, the board's via rule, or 0.6/0.3); via_style reads a standard {kind:via, sizeMm, drillMm} rule; every engine reply names its version (engine)

Colby Knox ·add2085e12 ·13d ago ·parent 36592f3
9 files changed +502−22
README.md+7−4
@@ -41,8 +41,9 @@ A verb one bridge gains should become every bridge's to-do: that is what this pa [`engine/`](engine/README.md) is `adom-eda-checks`, the checks tier as one Rust crate and command-line tool: the board model and its companions go in as plain JSON, findings and plans come out, and nothing calls a bridge or a tool. A bridge links the crate (vendored at a tagged standard version, since Cargo cannot fetch from the wiki) or runs the command, and-answers each check under its own prefix. It now answers `copper_balance`, `return_path`, `fab_check`, `impedance` and-`current_capacity`, moved over from the Altium bridge and giving the same answers on Altium's test boards. It also+answers each check under its own prefix. It now answers `copper_balance`, `return_path`, `fab_check`, `impedance`,+`current_capacity`, `thermal_vias` and `via_fence`, moved over from the Altium bridge (the first five give the same+answers as the Altium bridge on its test boards). It also carries board-file writers (`export_gencad`, `export_dxf`, `import_svg`) for a bridge whose tool has no native exporter. Its conformance tests read a bridge's saved replies from `ADOM_EDA_FIXTURES`; fixtures live on each bridge's own page (Altium's: [adom/altium-bridge](https://wiki.adom.inc/adom/altium-bridge) `fixtures/`).@@ -61,9 +62,9 @@ verb by verb: [docs/coverage.md](docs/coverage.md). | **core** | 93 | 93 | 89 | 79 | | **native** | 46 | 46 | 43 | 29 | | **checks** | 37 | 33 | 34 | 13 |-| **app** | 60 | 60 | 25 | 37 |+| **app** | 60 | 60 | 34 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 191 | 158 |+| standard (all but platform) | 236 | 232 | 200 | 158 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -156,6 +157,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.6.0: the engine (0.3.0) adds `thermal_vias` and `via_fence` from the Altium bridge, as plans in `place_vias`+arguments, so KiCad and Fusion can swap their own copies for one; every engine reply names its version (`engine`). Version 0.5.1: the engine (0.2.1) writes one GenCAD shape per footprint pattern and pad geometry: two different footprints of the same name get NAME and NAME_2, and a rotated part's micron-off pins still share their shape (fusion-lane #17).
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.2.1"+version = "0.3.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.2.1"+version = "0.3.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+9−2
@@ -16,9 +16,15 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `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 |+| `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 |++The via plans size their vias from `args.size`/`drill`, else the board's via rule (`rules`: `{kind: "via", sizeMm, drillMm}`,+or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`.  The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning,-designators, via fences, panels, the board report) come over from the Altium bridge a module at a time.+designators, panels, the board report, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout+and plan_route) come over a module at a time.  ## Writers @@ -78,7 +84,8 @@ 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`): a bridge adds its own.+(`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`+(`"adom-eda-checks 0.3.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/features.rsadded+411
@@ -0,0 +1,411 @@+//! Via plans computed from the board model: thermal vias in a part's exposed pad and a via fence along RF tracks or the+//! board edge (and the stitching grid behind board_features). From the Altium bridge's features.rs. Plans only: each+//! answers the vias as place_vias arguments, and the bridge places them.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn pt(v: &Value) -> (f64, f64) { (f(&v[0]), f(&v[1])) }++fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {+    let mut c = false;+    let n = poly.len();+    for i in 0..n {+        let (a, b) = (poly[i], poly[(i + n - 1) % n]);+        if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+    }+    c+}++fn seg_dist(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 == 0.0 { 0.0 } else { (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0) };+    ((px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2)).sqrt()+}++/// Copper that a stitching via on `net` must keep clear of: (kind, geometry, half-width) as segments and discs.+enum Ob { Seg((f64, f64), (f64, f64), f64), Disc(f64, f64, f64) }++fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) {+    // copper on another net. Lines on mechanical and overlay layers are not copper, and net-less copper tracks are a+    // hatched pour's pieces (Altium lists them with no net) that re-pour round a new via+    let other = |v: &Value| {+        let n = v["net"].as_str().unwrap_or("");+        let copper = v.get("layer").map(|l| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false)).unwrap_or(true);+        let piece = n.is_empty() && v.get("start").or(v.get("center")).is_some();+        copper && !piece && !n.eq_ignore_ascii_case(net)+    };+    let mut ob = Vec::new();+    let mut same_vias = Vec::new();+    for t in state["tracks"].as_array().cloned().unwrap_or_default() {+        if other(&t) { ob.push(Ob::Seg((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)); }+    }+    for a in state["arcs"].as_array().cloned().unwrap_or_default() {+        if !other(&a) { continue; }+        // an arc as short chords+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (mut a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+        let mut span = a2 - a1;+        if span <= 0.0 { span += 360.0; }+        let n = ((span / 10.0).ceil() as usize).max(1);+        let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+        for _ in 0..n { let b = a1 + span / n as f64; ob.push(Ob::Seg(p(a1), p(b), f(&a["width"]) / 2.0)); a1 = b; }+    }+    for v in state["vias"].as_array().cloned().unwrap_or_default() {+        let (x, y) = (f(&v["position"]["x"]), f(&v["position"]["y"]));+        if other(&v) { ob.push(Ob::Disc(x, y, f(&v["size"]) / 2.0)); } else { same_vias.push((x, y)); }+    }+    for p in state["pads"].as_array().cloned().unwrap_or_default() {+        // a pad on the net still gets clearance: a via in a pad is not stitching. Its circumscribed circle keeps it+        // conservative at any rotation.+        let r = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+        ob.push(Ob::Disc(f(&p["x"]), f(&p["y"]), r.max(f(&p["drill"]) / 2.0)));+    }+    (ob, same_vias)+}++/// Where stitching vias for `net` go: a grid at `pitch` inside the outline (or `region`), `edge` mm clear of the+/// board edge, `clearance` mm clear of copper on other nets and of every pad, half a pitch from vias the net already+/// has, at most `max`. Pure, for the tests.+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {+    plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true, &[])+}++/// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with+/// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance).+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,+                           extra: &[(f64, f64, f64)], avoid_parts: bool, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+    let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+    if outline.len() < 3 || pitch <= 0.0 { return vec![]; }+    let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());+    let (mut ob, same) = obstacles(state, net);+    for (x, y, r) in extra { ob.push(Ob::Disc(*x, *y, *r)); }+    // each part's pad box: a via between a chip's pad rows sits under its body+    let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = Default::default();+    if avoid_parts {+        for p in state["pads"].as_array().cloned().unwrap_or_default() {+            let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+            let h = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+            let (x, y) = (f(&p["x"]), f(&p["y"]));+            let b = boxes.entry(r.to_string()).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN));+            *b = (b.0.min(x - h), b.1.min(y - h), b.2.max(x + h), b.3.max(y + h));+        }+    }+    let (x0, x1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let mut cands = Vec::new();+    let mut y = y0 + pitch / 2.0;+    while y < y1 {+        let mut x = x0 + pitch / 2.0;+        while x < x1 { if inside(&area, x, y) { cands.push((x, y)); } x += pitch; }+        y += pitch;+    }+    keep_clear(state, &outline, &cands, pitch / 2.0, size, clearance, edge, max, &ob, &same, &boxes, keepouts)+}++/// The candidate via points that fit: inside the outline, `edge` from it, `clearance` from other copper (`ob`), out of+/// part boxes and keepouts, and `sep` from the net's vias and from each other. Pure.+#[allow(clippy::too_many_arguments)]+fn keep_clear(_state: &Value, outline: &[(f64, f64)], cands: &[(f64, f64)], sep: f64, size: f64, clearance: f64, edge: f64, max: usize, ob: &[Ob],+              same: &[(f64, f64)], boxes: &std::collections::BTreeMap<String, (f64, f64, f64, f64)>, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+    let r = size / 2.0;+    let mut out: Vec<(f64, f64)> = Vec::new();+    for &(x, y) in cands {+        if out.len() >= max { break; }+        let ok = inside(outline, x, y)+            && (0..outline.len()).all(|k| seg_dist(x, y, outline[k], outline[(k + 1) % outline.len()]) >= edge + r)+            && ob.iter().all(|o| match o {+                Ob::Seg(a, b, hw) => seg_dist(x, y, *a, *b) - hw - r >= clearance,+                Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,+            })+            && same.iter().chain(out.iter()).all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= sep)+            && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3)+            && keepouts.iter().all(|k| x + r <= k.0 || x - r >= k.2 || y + r <= k.1 || y - r >= k.3);+        if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }+    }+    out+}++/// Via fence candidates: both sides of every copper track of `nets`, `offset` from its centreline, every `pitch` along+/// it; and with `edge_inset`, along the outline that far inside it. Pure.+pub fn fence_points(state: &Value, nets: &[String], pitch: f64, offset: f64, edge_inset: Option<f64>) -> Vec<(f64, f64)> {+    let mut v = Vec::new();+    for t in state["tracks"].as_array().cloned().unwrap_or_default() {+        let n = t["net"].as_str().unwrap_or("");+        if !nets.iter().any(|x| x.eq_ignore_ascii_case(n)) || !t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false) { continue; }+        let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+        let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+        if len <= 0.0 { continue; }+        let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+        let k = (len / pitch).floor() as usize;+        for i in 0..=k {+            let s = if k == 0 { len / 2.0 } else { i as f64 * len / k as f64 };+            for side in [1.0, -1.0] { v.push((a.0 + ux * s - uy * offset * side, a.1 + uy * s + ux * offset * side)); }+        }+    }+    if let Some(inset) = edge_inset {+        let ring = crate::coppermap::outline_ring(state);+        let n = ring.len();+        // inward is left of each edge on a counterclockwise ring+        let area: f64 = (0..n).map(|k| ring[k].0 * ring[(k + 1) % n].1 - ring[(k + 1) % n].0 * ring[k].1).sum();+        let sgn = if area > 0.0 { 1.0 } else { -1.0 };+        let mut carry: f64 = 0.0;+        for k in 0..n {+            let (a, b) = (ring[k], ring[(k + 1) % n]);+            let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+            if len <= 0.0 { continue; }+            let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+            // clear of the corners by the inset, where an inward step from one edge lands on the next+            let mut s = carry.max(inset);+            while s <= len - inset { v.push((a.0 + ux * s - uy * inset * sgn, a.1 + uy * s + ux * inset * sgn)); s += pitch; }+            carry = (s - len).max(0.0);+        }+    }+    v+}++/// The board's preferred via (diameter, drill) in mm. A standard rule gives it as `{kind: "via", sizeMm, drillMm}`; Altium's+/// Routing Via descriptor is read too, e.g. "Routing Via (MinHoleWidth=0.3mm) ... (PreferredHoleWidth=0.3mm) ...+/// (PreferedWidth=0.5mm) (All)" (Altium's own spelling). Pure.+pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> {+    if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+        .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("via")).unwrap_or(false) && r["sizeMm"].as_f64().is_some() && r["drillMm"].as_f64().is_some()) {+        return Some((f(&r["sizeMm"]), f(&r["drillMm"])));+    }+    let num = |d: &str, key: &str| -> Option<f64> {+        let i = d.find(&format!("({key}="))? + key.len() + 2;+        let s = &d[i..d[i..].find(')')? + i];+        let (v, unit) = s.trim_end_matches(char::is_alphabetic).parse::<f64>().ok().map(|v| (v, &s[s.trim_end_matches(char::is_alphabetic).len()..]))?;+        Some(if unit.eq_ignore_ascii_case("mil") { v * 0.0254 } else { v })+    };+    let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Routing Via")).unwrap_or(false)).collect();+    // the board-wide rule (scope All), highest priority first+    rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+    let d = rs.first()?["descriptor"].as_str()?;+    Some((num(d, "PreferedWidth").or(num(d, "PreferredWidth"))?, num(d, "PreferredHoleWidth")?))+}+++/// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus+/// the via's radius, centred. Pure.+pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {+    let (ux, uy) = (sx / 2.0 - inset - size / 2.0, sy / 2.0 - inset - size / 2.0);+    if ux < 0.0 || uy < 0.0 { return vec![]; }+    let (nx, ny) = ((2.0 * ux / pitch).floor() as i64 + 1, (2.0 * uy / pitch).floor() as i64 + 1);+    let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+    let mut out = Vec::new();+    for i in 0..nx { for j in 0..ny {+        let (lx, ly) = ((i as f64 - (nx - 1) as f64 / 2.0) * pitch, (j as f64 - (ny - 1) as f64 / 2.0) * pitch);+        out.push((((x + lx * c - ly * s) * 1000.0).round() / 1000.0, ((y + lx * s + ly * c) * 1000.0).round() / 1000.0));+    } }+    out+}++/// The via each plan uses: `size`/`drill` from args, else the board's via rule (`rules`), else 0.6/0.3; with where it came from.+fn via_of(input: &Value) -> (f64, f64, &'static str) {+    let args = &input["args"];+    let style = via_style(&crate::input::rules(input));+    let from = if args.get("size").is_some() { "size/drill" } else if style.is_some() { "the board's via rule" } else { "default 0.6/0.3" };+    (num(args, "size", style.map(|s| s.0).unwrap_or(0.6)), num(args, "drill", style.map(|s| s.1).unwrap_or(0.3)), from)+}++/// The plan as place_vias arguments.+fn apply_of(pts: &[(f64, f64)], net: &str, size: f64, drill: f64) -> Value {+    json!({"verb": "place_vias", "args": {"vias": pts.iter().map(|(x, y)| json!({"net": net, "x": x, "y": y, "size": size, "drill": drill})).collect::<Vec<_>>()}})+}++/// thermal_vias: input {board, rules?, args: {ref, pad?, pitchMm?, size?, drill?, insetMm?}}. A grid of vias inside the part's+/// exposed pad (the one named, else its largest), clear of vias already there. Plans only: `apply` is place_vias' arguments.+pub fn thermal_vias(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(rf) = args["ref"].as_str() else { return crate::input::refuse("bad_args", "ref names the part (e.g. U3)") };+    let pads: Vec<Value> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(rf)).unwrap_or(false)).collect();+    if pads.is_empty() { return crate::input::refuse("part_not_found", &format!("{rf} has no pads on this board")); }+    let pad = match args["pad"].as_str() {+        Some(n) => pads.iter().find(|p| p["name"].as_str().map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false)).cloned(),+        None => pads.iter().max_by(|a, b| (f(&a["sizeX"]) * f(&a["sizeY"])).partial_cmp(&(f(&b["sizeX"]) * f(&b["sizeY"]))).unwrap()).cloned(),+    };+    let Some(pad) = pad else { return json!({"success": false, "errorCode": "no_such_pad", "error": "that pad is not on the part", "pads": pads.iter().map(|p| p["name"].clone()).collect::<Vec<_>>()}) };+    let net = pad["net"].as_str().unwrap_or("").to_string();+    if net.is_empty() { return crate::input::refuse("pad_has_no_net", &format!("{rf}.{} is on no net: thermal vias would float", pad["name"].as_str().unwrap_or(""))); }+    let (size, drill, from) = via_of(input);+    let pitch = num(args, "pitchMm", (size + 0.5).max(1.0));+    let inset = num(args, "insetMm", 0.1);+    let grid = thermal_grid(f(&pad["x"]), f(&pad["y"]), f(&pad["sizeX"]), f(&pad["sizeY"]), f(&pad["rotation"]), pitch, size, inset);+    // keep clear of vias already there: the Bluetooth Sentinel's U4 pad had 8, and a grid laid over them broke Altium's+    // hole-to-hole rule 8 times (2026-10-26)+    let vias: Vec<(f64, f64, f64)> = st["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]))).collect();+    let half = f(&pad["sizeX"]).max(f(&pad["sizeY"])) / 2.0;+    let existing = vias.iter().filter(|(x, y, _)| (x - f(&pad["x"])).abs() <= half && (y - f(&pad["y"])).abs() <= half).count();+    let pts: Vec<(f64, f64)> = grid.iter().cloned().filter(|(x, y)| vias.iter().all(|(vx, vy, vs)| ((x - vx).powi(2) + (y - vy).powi(2)).sqrt() >= size / 2.0 + vs / 2.0 + 0.2)).collect();+    let planned = json!({"part": rf, "pad": pad["name"], "net": net, "padMm": [pad["sizeX"], pad["sizeY"]], "vias": pts.len(), "existingVias": existing, "skippedNearExisting": grid.len() - pts.len(), "pitchMm": pitch,+                         "via": {"sizeMm": size, "drillMm": drill, "from": from}, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+    if pts.is_empty() {+        return json!({"success": existing > 0, "errorCode": if existing > 0 { "already_has_vias" } else { "pad_too_small" }, "error": if existing > 0 { "the pad's existing vias leave no room for more at this pitch" } else { "no via fits inside the pad at this size and inset" }, "planned": planned});+    }+    json!({"success": true, "planned": planned, "apply": apply_of(&pts, &net, size, drill),+        "_hint": "A plan only: run place_vias with apply.args. Vias inside the pad carry its heat to the other layers' copper: pour the net on the far side (apply_pours) so they have somewhere to go. Open vias in a pad wick solder: ask the fab to tent or plug them, or use 0.3 mm or smaller drills. run_drc afterwards."})+}++/// via_fence: input {board, rules?, args: {viaNet, nets?, edge?, pitchMm?, freqGHz?, offsetMm?, edgeInsetMm?, size?, drill?,+/// clearance?}}. Vias of viaNet both sides of the named nets' tracks and/or round the board edge, clear of other copper.+/// Plans only: `apply` is place_vias' arguments.+pub fn via_fence(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(via_net) = args["viaNet"].as_str() else { return crate::input::refuse("bad_args", "viaNet is the net the fence ties to, usually GND") };+    if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(via_net)).unwrap_or(false))).unwrap_or(false) {+        return json!({"success": false, "errorCode": "no_such_net", "error": format!("{via_net} is not a net on this board"), "nets": st["nets"]});+    }+    let nets: Vec<String> = args.get("nets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+    let edge_on = args.get("edge").and_then(|b| b.as_bool()).unwrap_or(false);+    if nets.is_empty() && !edge_on { return crate::input::refuse("bad_args", "name nets to fence (e.g. [\"ANT_RF\"]) and/or edge:true for a fence round the board edge"); }+    // the via the board's own via rule prefers, so the fence passes its DRC (0.6/0.3 broke the Bluetooth Sentinel+    // example's 0.5/0.3 rule on every via)+    let (size, drill, from) = via_of(input);+    let clearance = num(args, "clearance", 0.2);+    // lambda/20 in FR-4 (effective er about 3.4) at the highest frequency, else 2 mm+    let pitch = match args.get("freqGHz").and_then(|v| v.as_f64()) { Some(g) if g > 0.0 => (299.79 / g / 3.4f64.sqrt() / 20.0).min(num(args, "pitchMm", f64::MAX)), _ => num(args, "pitchMm", 2.0) };+    let widest = st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| nets.iter().any(|n| t["net"].as_str().map(|s| s.eq_ignore_ascii_case(n)).unwrap_or(false))).map(|t| f(&t["width"])).fold(0.0, f64::max);+    let offset = num(args, "offsetMm", widest / 2.0 + clearance + size / 2.0 + 0.1);+    let inset = if edge_on { Some(num(args, "edgeInsetMm", size / 2.0 + 0.5)) } else { None };+    let cands = fence_points(&st, &nets, pitch, offset, inset);+    let outline: Vec<(f64, f64)> = crate::coppermap::outline_ring(&st);+    let (ob, same) = obstacles(&st, via_net);+    let pts = keep_clear(&st, &outline, &cands, pitch * 0.6, size, clearance, 0.3, 5000, &ob, &same, &Default::default(), &[]);+    let planned = json!({"vias": pts.len(), "candidates": cands.len(), "via": {"sizeMm": size, "drillMm": drill, "from": from}, "pitchMm": (pitch * 1000.0).round() / 1000.0,+                         "offsetMm": (offset * 1000.0).round() / 1000.0, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+    if pts.is_empty() {+        return json!({"success": false, "errorCode": "no_room", "error": "no fence via fits", "planned": planned, "_hint": "Lower clearance or size, change offsetMm, or check the nets have tracks."});+    }+    json!({"success": true, "planned": planned, "apply": apply_of(&pts, via_net, size, drill),+        "_hint": "A plan only: run place_vias with apply.args, then repour so pours join the vias, then run_drc. candidates - vias are the spots where other copper, a pad or the edge was too close."})+}++/// The four corner positions of the outline's bounding box, `inset` mm in.+pub fn corners(state: &Value, inset: f64) -> Vec<(f64, f64)> {+    let o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+    if o.is_empty() { return vec![]; }+    let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    vec![(x0 + inset, y0 + inset), (x1 - inset, y0 + inset), (x1 - inset, y1 - inset), (x0 + inset, y1 - inset)]+}++pub fn region_of(v: Option<&Value>) -> Option<Vec<(f64, f64)>> {+    let a = v?.as_array()?;+    let r: Vec<(f64, f64)> = a.iter().map(pt).collect();+    if r.len() >= 3 { Some(r) } else { None }+}++pub fn summary(points: &[(f64, f64)]) -> Value {+    json!(points.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>())+}++fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(d) }++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+               "tracks": [{"start": {"x": 0.0, "y": 5.0}, "end": {"x": 20.0, "y": 5.0}, "width": 0.25, "net": "SIG"}],+               "arcs": [], "vias": [{"position": {"x": 2.5, "y": 2.5}, "size": 0.6, "net": "GND"}],+               "pads": [{"x": 17.5, "y": 7.5, "sizeX": 1.0, "sizeY": 1.0, "drill": 0.0, "net": "GND"}]})+    }++    #[test]+    fn stitching_keeps_clear_of_other_nets_pads_edges_and_existing_vias() {+        let p = plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 100);+        // the 5 mm grid is 2.5/7.5/12.5/17.5 by 2.5/7.5; (2.5,2.5) has a GND via already, (17.5,7.5) is a pad+        assert_eq!(p, vec![(7.5, 2.5), (12.5, 2.5), (17.5, 2.5), (2.5, 7.5), (7.5, 7.5), (12.5, 7.5)]);+        // a track of another net through the grid row blocks it+        let mut b = board();+        b["tracks"][0]["start"]["y"] = json!(2.5);+        b["tracks"][0]["end"]["y"] = json!(2.5);+        assert!(plan_stitching(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100).iter().all(|p| p.1 != 2.5));+        // a region limits it, max caps it+        let reg = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+        assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, Some(&reg), 100), vec![(7.5, 2.5), (2.5, 7.5), (7.5, 7.5)]);+        assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 2).len(), 2);+    }++    #[test]+    fn stitching_stays_off_holes_and_out_from_under_parts() {+        let mut b = board();+        // a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body+        b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},+                           {"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true, &[(15.0, 0.0, 20.0, 10.0)]);+        assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));+        assert!(p.iter().all(|q| q.0 < 15.0), "the keepout at x 15..20 takes no via");+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false, &[]);+        assert!(p.contains(&(7.5, 7.5)));+    }++    #[test]+    fn corner_holes_sit_inset_from_the_outline() {+        assert_eq!(corners(&board(), 3.0), vec![(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (3.0, 7.0)]);+    }+    #[test]+    fn fence_points_line_both_sides_of_a_track_and_the_edge() {+        let st = json!({"tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 0.0, "y": 5.0}, "end": {"x": 10.0, "y": 5.0}}],+                        "outline": [[-5, 0], [15, 0], [15, 10], [-5, 10]],+                        "outlineEdges": [{"kind": "line", "start": [-5.0, 0.0]}, {"kind": "line", "start": [15.0, 0.0]}, {"kind": "line", "start": [15.0, 10.0]}, {"kind": "line", "start": [-5.0, 10.0]}]});+        let p = fence_points(&st, &["RF".to_string()], 2.0, 1.0, None);+        assert_eq!(p.len(), 12);+        assert!(p.contains(&(0.0, 6.0)) && p.contains(&(10.0, 4.0)));+        let e = fence_points(&st, &[], 5.0, 1.0, Some(0.5));+        assert!(e.iter().all(|(x, y)| *x >= -4.5 - 1e-9 && *x <= 14.5 + 1e-9 && *y >= 0.5 - 1e-9 && *y <= 9.5 + 1e-9), "{e:?}");+        assert!(e.len() >= 10, "{e:?}");+    }+    #[test]+    fn via_style_reads_altiums_descriptor() {+        let rules = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (MinHoleWidth=0.3mm) (MaxHoleWidth=0.3mm) (PreferredHoleWidth=0.3mm) (MinWidth=0.5mm) (MaxWidth=0.5mm) (PreferedWidth=0.5mm) (All)"})];+        assert_eq!(via_style(&rules), Some((0.5, 0.3)));+        let mil = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=12mil) (PreferedWidth=24mil) (All)"})];+        let (s, d) = via_style(&mil).unwrap();+        assert!((s - 0.6096).abs() < 1e-9 && (d - 0.3048).abs() < 1e-9);+    }+    #[test]+    fn thermal_grid_fills_an_exposed_pad() {+        // a 3.3 mm QFN pad, 0.6 mm vias, 1.1 mm pitch: a 3 x 3 grid centred on the pad+        let g = thermal_grid(10.0, 10.0, 3.3, 3.3, 0.0, 1.1, 0.6, 0.1);+        assert_eq!(g.len(), 9);+        assert!(g.contains(&(10.0, 10.0)) && g.contains(&(8.9, 11.1)));+        assert!(thermal_grid(0.0, 0.0, 0.5, 0.5, 0.0, 1.0, 0.6, 0.1).is_empty());+        // turned 90 degrees, a long pad's grid turns with it+        let r = thermal_grid(0.0, 0.0, 4.0, 1.5, 90.0, 1.1, 0.6, 0.1);+        assert!(r.iter().all(|(x, _)| x.abs() < 0.5));+    }++    #[test]+    fn via_style_takes_the_standard_rule_first() {+        let rules = vec![json!({"kind": "via", "sizeMm": 0.45, "drillMm": 0.2}),+                         json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"})];+        assert_eq!(via_style(&rules), Some((0.45, 0.2)));+    }++    #[test]+    fn the_verbs_answer_place_vias_plans() {+        let st = json!({"nets": ["GND", "RF"], "outline": [[0, 0], [30, 0], [30, 20], [0, 20]],+            "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "line", "start": [30.0, 20.0]}, {"kind": "line", "start": [0.0, 20.0]}],+            "tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 5.0, "y": 10.0}, "end": {"x": 25.0, "y": 10.0}}], "arcs": [], "vias": [],+            "pads": [{"ref": "U1", "name": "EP", "x": 15.0, "y": 4.0, "sizeX": 3.3, "sizeY": 3.3, "rotation": 0.0, "drill": 0.0, "net": "GND", "layer": "F.Cu"}]});+        let t = thermal_vias(&json!({"board": st, "rules": [{"kind": "via", "sizeMm": 0.6, "drillMm": 0.3}], "args": {"ref": "U1"}}));+        assert_eq!(t["success"], json!(true), "{t}");+        assert_eq!(t["planned"]["vias"], 9);+        assert_eq!(t["apply"]["verb"], "place_vias");+        assert_eq!(t["apply"]["args"]["vias"][0]["net"], "GND");+        assert_eq!(t["planned"]["via"]["from"], "the board's via rule");+        let v = via_fence(&json!({"board": st, "args": {"viaNet": "GND", "nets": ["RF"], "pitchMm": 2.0}}));+        assert_eq!(v["success"], json!(true), "{v}");+        assert!(v["planned"]["vias"].as_u64().unwrap() >= 10);+        assert_eq!(v["apply"]["args"]["vias"].as_array().unwrap().len() as u64, v["planned"]["vias"].as_u64().unwrap());+        assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");+        assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");+    }+}
engine/src/lib.rs+25−2
@@ -10,16 +10,27 @@ use serde_json::Value; pub mod coppermap; pub mod current; pub mod fab;+pub mod features; pub mod geom; pub mod impedance; pub mod input; pub mod writers; +/// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand+/// (kicad-lane's replies said 0.1.2 for three releases after re-vendoring).+pub const VERSION: &str = env!("CARGO_PKG_VERSION");+ /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path", "thermal_vias", "via_fence"]; -/// Run one check or writer by its standard name.+/// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {+    let mut out = answer(check, input);+    if let Some(o) = out.as_object_mut() { o.entry("engine").or_insert(Value::String(format!("adom-eda-checks {VERSION}"))); }+    out+}++fn answer(check: &str, input: &Value) -> Value {     if let Some(out) = writers::run(check, input) { return out; }     match check {         "copper_balance" => coppermap::copper_balance(input),@@ -27,6 +38,18 @@ pub fn run(check: &str, input: &Value) -> Value {         "fab_check" => fab::fab_check(input),         "impedance" => impedance::impedance(input),         "return_path" => coppermap::return_path(input),+        "thermal_vias" => features::thermal_vias(input),+        "via_fence" => features::via_fence(input),         _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),     } }++#[cfg(test)]+mod tests {+    #[test]+    fn every_reply_names_the_engine_version() {+        let r = super::run("copper_balance", &serde_json::json!({}));+        assert_eq!(r["engine"], format!("adom-eda-checks {}", super::VERSION));+        assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);+    }+}
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.5.1",+  "version": "0.6.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+46−10
@@ -1,5 +1,5 @@ {- "standard": "0.5.1",+ "standard": "0.6.0",  "reference": {   "bridge": "altium",   "version": "0.2.78"@@ -2913,7 +2913,11 @@     "verb": "altium_check_dialogs",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_state / kicad_dismiss_dialogs",+    "match": "partial",+    "note": "state lists every KiCad window, dialogs included, each as a PNG; dismiss_dialogs clears the benign ones, or all"+   },    "fusion360": {     "verb": "fusion_check_dialogs",     "match": "exact"@@ -2944,7 +2948,11 @@     "verb": "altium_close_all_documents",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_close",+    "match": "partial",+    "note": "closes KiCad, editors first, then the project manager; force:true discards unsaved work"+   },    "fusion360": {     "verb": "fusion_close_all_documents",     "match": "exact"@@ -2958,7 +2966,11 @@     "verb": "altium_close_document",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_close_symbol_editor / kicad_close_footprint_editor / kicad_close_3d_viewer",+    "match": "partial",+    "note": "closes one editor window; KiCad holds one board and one schematic per project, so there is no per-document close"+   },    "fusion360": {     "verb": "fusion_close_document",     "match": "exact"@@ -3079,7 +3091,11 @@     "verb": "altium_document_info",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_state",+    "match": "partial",+    "note": "every open KiCad window with the file it holds; unsaved changes show as the title's leading '*'"+   },    "fusion360": {     "verb": "fusion_document_info",     "match": "exact"@@ -3137,7 +3153,11 @@     "verb": "altium_install",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_upgrade",+    "match": "partial",+    "note": "KiCad's official installer in one call, not staged; refused while KiCad runs"+   },    "fusion360": {     "verb": "fusion_install_fusion",     "match": "exact"@@ -3173,7 +3193,11 @@     "verb": "altium_instances",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_state",+    "match": "partial",+    "note": "KiCad runs one project manager; state lists every window it owns"+   },    "fusion360": null   },   {@@ -3240,7 +3264,11 @@     "verb": "altium_list_commands",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_menu_items",+    "match": "partial",+    "note": "a window's menu bar items (path, id, accelerator), not a command catalog"+   },    "fusion360": {     "verb": "fusion_list_commands",     "match": "exact"@@ -3271,7 +3299,11 @@     "verb": "altium_open_project",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_open_board / kicad_open_schematic",+    "match": "partial",+    "note": "opens the project's board or schematic in its editor"+   },    "fusion360": null   },   {@@ -3324,7 +3356,11 @@     "verb": "altium_run_command",     "match": "exact"    },-   "kicad": null,+   "kicad": {+    "verb": "kicad_send_key",+    "match": "partial",+    "note": "fires a menu item by its label, or a hotkey; no command parameters"+   },    "fusion360": {     "verb": "fusion_run_command",     "match": "exact"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.5.1",+ "standard": "0.6.0",  "reference": {   "bridge": "altium",   "version": "0.2.78"