skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
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.
← Commit history
standard 0.21.0: engine 0.18.0 adds footprint_from_land_pattern; kicad 1.0.84
10 files changed
+292−10
README.md+3@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.21.0: the engine (0.18.0) adds `footprint_from_land_pattern`, the Altium bridge's footprint layout, so every bridge lays a+footprint out from a datasheet land pattern the same way and writes it in its own format (fusion-lane). On a QFN-16 the+silkscreen keeps 0.2 mm from copper and the courtyard holds every pad. Version 0.20.3: the engine (0.17.3) reads a standard width rule's `minMm` when it has no preferred width, so plan_route routes at Fusion's EAGLE minimum width (fusion-lane). Version 0.20.2: the engine (0.17.2) classes a rule by its own name only when it has no Altium ordinal, so Altium's other rules
docs/coverage.md+1−1@@ -1,6 +1,6 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.81 (the reference), KiCad bridge 1.0.83, Fusion 360 bridge 1.13.13. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.81 (the reference), KiCad bridge 1.0.84, Fusion 360 bridge 1.13.13. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand.
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.17.3"+version = "0.18.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.17.3"+version = "0.18.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@@ -23,6 +23,7 @@ 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 |+| `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `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 | | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `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 |@@ -112,7 +113,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.17.3"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.18.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/landpattern.rsadded+276@@ -0,0 +1,276 @@+//! Footprints from a datasheet's land pattern: the AI gives the numbers the datasheet prints (pitch, pad size, row+//! span, body), the engine lays out a complete footprint: pads numbered the standard way, an exposed pad, the courtyard,+//! silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer (mm, Y up, origin at the+//! centre). From the Altium bridge's footprint.rs; each bridge writes the layout in its own library format.+use serde_json::{json, Value};++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }++#[derive(Debug, Default)]+pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }++/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...+pub fn bga_row(i: usize) -> String {+ const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";+ if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }+}++/// The pads of a land pattern. Errors name the missing number in the words the caller used.+pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));+ let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();+ let mut out = Vec::new();+ let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });+ match family.as_str() {+ "chip" | "two" | "2pad" => {+ let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);+ push("1".into(), -span / 2.0, 0.0, pl, pw);+ push("2".into(), span / 2.0, 0.0, pl, pw);+ }+ "dual" | "sot" => {+ let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);+ let (left, right) = if family == "sot" {+ (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)+ } else {+ let n = need("pins")? as usize;+ if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }+ (n / 2, n / 2)+ };+ // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)+ for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }+ for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+ }+ "quad" => {+ let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);+ let span_x = need("span")?;+ let span_y = num(a, "spanY").unwrap_or(span_x);+ let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {+ (Some(y), Some(x)) => (y as usize, x as usize),+ _ => {+ let n = need("pins")? as usize;+ if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }+ (n / 4, n / 4)+ }+ };+ let mut k = 1;+ // left top->bottom, bottom left->right, right bottom->top, top right->left+ for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }+ for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }+ }+ "bga" => {+ let (pitch, d) = (need("pitch")?, need("ballPad")?);+ let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);+ let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();+ for r in 0..rows {+ for c in 0..cols {+ let name = format!("{}{}", bga_row(r), c + 1);+ if missing.contains(&name) { continue; }+ push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);+ }+ }+ }+ f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),+ }+ // the exposed (thermal) pad+ if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {+ let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);+ let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());+ out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });+ }+ Ok(out)+}++/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.+fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {+ blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+ let mut out = Vec::new();+ let mut cur = a0;+ for &(b0, b1) in blocked.iter() {+ if b1 <= cur || b0 >= a1 { continue; }+ if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }+ cur = cur.max(b1);+ if cur >= a1 { break; }+ }+ if a1 - cur >= min { out.push((cur, a1)); }+ out+}++/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),+/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).+pub fn layout(a: &Value) -> Result<Fp, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let pads = pads(a)?;+ if pads.is_empty() { return Err("no pads".into()); }+ let body = a.get("body").cloned().unwrap_or(json!({}));+ let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));+ let silk_w = num(a, "silkWidth").unwrap_or(0.15);+ let clear = num(a, "silkClearance").unwrap_or(0.2);+ let margin = num(a, "courtyard").unwrap_or(0.25);+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+ let mut fp = Fp { pads: pads.clone(), ..Default::default() };+ if let (Some(w), Some(h)) = (bw, bh) {+ let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);+ x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);+ // silkscreen: each body edge minus the stretches that come within `clear` of copper+ let keep = clear + silk_w / 2.0;+ let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {+ let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {+ let (px0, py0, px1, py1) = ext(p);+ if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }+ }).collect();+ for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {+ if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }+ }+ };+ // the outline is drawn just outside the body so it is not hidden under it+ let o = silk_w / 2.0;+ edge(true, by1 + o, bx0 - o, bx1 + o);+ edge(true, by0 - o, bx0 - o, bx1 + o);+ edge(false, bx0 - o, by0 - o, by1 + o);+ edge(false, bx1 + o, by0 - o, by1 + o);+ // fab: the body with its pin-1 corner (top-left) cut+ let ch = (w.min(h) * 0.25).min(1.0);+ let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];+ for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }+ }+ // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized+ let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);+ if family != "chip" || polar {+ let p1 = &pads[0];+ let r = 0.15;+ let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };+ fp.discs.push(("overlay", r3(dx), r3(dy), r));+ x0 = x0.min(dx - r);+ y1 = y1.max(dy + r);+ }+ // courtyard: everything grown by the margin, on a 0.01 mm grid+ let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };+ let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));+ fp.courtyard = c;+ for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {+ fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+ }+ Ok(fp)+}++/// A plain description: "SOIC-8, 1.27 mm pitch, 8 pads".+fn describe(a: &Value, fp: &Fp) -> String {+ let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");+ let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();+ format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())+}++/// footprint_from_land_pattern: input {args: {name, family, ..., body?, exposedPad?, description?}}. The layout: pads,+/// lines and discs by layer (overlay, fab, courtyard), the courtyard box, a description and the pad table. A missing+/// number is refused in the caller's words.+pub fn footprint_from_land_pattern(input: &Value) -> Value {+ let args = &input["args"];+ let Some(name) = args["name"].as_str().filter(|n| !n.is_empty()) else { return crate::input::refuse("bad_args", "name is required") };+ let fp = match layout(args) {+ Ok(f) => f,+ Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e,+ "_hint": "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."}),+ };+ let desc = args["description"].as_str().map(String::from).unwrap_or_else(|| describe(args, &fp));+ let c = fp.courtyard;+ json!({"success": true, "name": name, "description": desc, "units": "mm", "frame": "Y up, origin at the centre, pin 1 top-left",+ "pads": fp.pads.iter().map(|p| json!({"name": p.name, "x": p.x, "y": p.y, "w": p.w, "h": p.h, "shape": p.shape, "cornerRatio": if p.shape == "roundrect" { 0.25 } else { 0.0 }, "hole": p.hole})).collect::<Vec<_>>(),+ "lines": fp.lines.iter().map(|(l, x1, y1, x2, y2, w)| json!({"layer": l, "x1": x1, "y1": y1, "x2": x2, "y2": y2, "width": w})).collect::<Vec<_>>(),+ "discs": fp.discs.iter().map(|(l, x, y, r)| json!({"layer": l, "x": x, "y": y, "r": r})).collect::<Vec<_>>(),+ "courtyardMm": [c.0, c.1, c.2, c.3], "body": args["body"].clone(),+ "padTable": fp.pads.iter().map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>(),+ "_hint": "A layout, not a library: write it in the tool's own footprint format (overlay is the silkscreen, fab the assembly outline, courtyard the keep-out), then check padTable against the datasheet. With body.height, generate_body makes the 3D body to attach."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn soic8_numbers_down_the_left_and_up_the_right() {+ let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));+ assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));+ assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));+ assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));+ assert_eq!((p[0].w, p[0].h), (1.55, 0.6));+ }++ #[test]+ fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {+ let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();+ assert_eq!(p.len(), 17);+ assert_eq!((p[0].x, p[0].y), (-1.5, 0.75)); // 1: left, top+ assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned+ assert_eq!((p[8].x, p[8].y), (1.5, -0.75)); // 9: right, bottom+ assert_eq!((p[12].x, p[12].y), (0.75, 1.5)); // 13: top, right end+ assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));+ }++ #[test]+ fn sot23_has_two_pins_left_and_one_right() {+ let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();+ assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+ }++ #[test]+ fn bga_rows_skip_confusable_letters() {+ assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));+ let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();+ assert_eq!(p.len(), 8);+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));+ }++ #[test]+ fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {+ let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});+ let fp = layout(&a).unwrap();+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {+ let _ = l;+ for p in &fp.pads {+ let (px0, py0, px1, py1) = ext(p);+ // distance from the segment (axis-aligned) to the pad box, minus half the stroke+ let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);+ let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);+ assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);+ }+ }+ // a SOIC's body sides sit under its pads: only the top and bottom edges survive+ assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);+ let c = fp.courtyard;+ assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));+ assert_eq!(fp.discs.len(), 1);+ assert!(fp.discs[0].1 < -2.7 - 0.775);++ }++ #[test]+ fn a_missing_number_is_named() {+ let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();+ assert!(e.contains("padLength"));+ }++ #[test]+ fn the_verb_answers_the_layout_in_the_standard_shape() {+ let r = footprint_from_land_pattern(&json!({"args": {"name": "SOIC-8", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["pads"][0], json!({"name": "1", "x": -2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "cornerRatio": 0.25, "hole": 0.0}));+ assert!(r["lines"].as_array().unwrap().iter().any(|l| l["layer"] == "courtyard"));+ assert_eq!(r["discs"][0]["layer"], "overlay");+ assert_eq!(r["padTable"].as_array().unwrap().len(), 8);+ assert!(r["description"].as_str().unwrap().starts_with("SOIC-8 dual, 1.27 mm pitch, 8 pads"));+ let e = footprint_from_land_pattern(&json!({"args": {"name": "X", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6}}));+ assert_eq!(e["errorCode"], "bad_args");+ assert!(e["error"].as_str().unwrap().contains("padLength"));+ assert_eq!(footprint_from_land_pattern(&json!({"args": {"family": "chip"}}))["errorCode"], "bad_args");+ }+}
engine/src/lib.rs+3−1@@ -18,6 +18,7 @@ pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod landpattern; pub mod lengths; pub mod lengthtune; pub mod pairroute;@@ -36,7 +37,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] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "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 {@@ -57,6 +58,7 @@ fn answer(check: &str, input: &Value) -> Value { "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input), "find_space" => space::find_space(input),+ "footprint_from_land_pattern" => landpattern::footprint_from_land_pattern(input), "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.20.3",+ "version": "0.21.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+2−2@@ -1,12 +1,12 @@ {- "standard": "0.20.3",+ "standard": "0.21.0", "reference": { "bridge": "altium", "version": "0.2.81" }, "proposed": 56, "compared": {- "kicad": "1.0.83",+ "kicad": "1.0.84", "fusion360": "1.13.13" }, "tiers": {
verbs/standard.json+2−2@@ -1,12 +1,12 @@ {- "standard": "0.20.3",+ "standard": "0.21.0", "reference": { "bridge": "altium", "version": "0.2.81" }, "proposed": 56, "compared": {- "kicad": "1.0.83",+ "kicad": "1.0.84", "fusion360": "1.13.13" }, "tiers": {