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.17.0: engine 0.14.0 adds find_space and check_orientation
9 files changed
+235−7
README.md+2@@ -158,6 +158,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.17.0: the engine (0.14.0) adds `find_space` and `check_orientation` from the Altium bridge. On the Bluetooth Sentinel the+spots found near U4 keep the 0.25 mm clearance from pads, routed tracks and vias (checked independently). Version 0.16.0: the engine (0.13.0) adds `auto_classes` and `silk_designators` from the Altium bridge. On the Bluetooth Sentinel every planned designator keeps 0.15 mm from every pad, overlaps no other label and stays on the board (checked independently).
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.13.0"+version = "0.14.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.13.0"+version = "0.14.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+3−1@@ -14,9 +14,11 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs | | `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools |+| `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) | | `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) | | `estimate_cost` | board, stack?, rules?, prices? | the numbers a fab quote needs (size, copper layers, thickness from the stack, smallest track, gap from the clearance rule, smallest drill, holes, SMD and through-hole parts and joints, unique parts, assembled sides) and, with a `prices` record, a rough table at `quantities` (default 5, 10, 30, 100), with assembly when asked, every assumption stated and the record named in `source`. The engine carries no rates: without `prices` the reply is the numbers only. From the KiCad bridge's fab.rs |+| `find_space` | board, bodies? | free spots for a part (`part`, or `sizeMm`) near another (`near`) or a point (`at`): clear of other part boxes on that side by `clearanceMm`, of that side's routed tracks and the vias, `edgeMm` inside the outline (arcs sampled), nearest first, as where the part's origin goes; `check: true` lists parts that overlap or leave the outline. Pours do not block (they re-pour). From the Altium bridge's design.rs | | `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 |@@ -100,7 +102,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.13.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.14.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/lib.rs+4−1@@ -23,6 +23,7 @@ pub mod placement; pub mod placetools; pub mod route; pub mod silkplace;+pub mod space; 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@@ -30,7 +31,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", "check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "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 {@@ -45,9 +46,11 @@ fn answer(check: &str, input: &Value) -> Value { "assembly_check" => assembly::assembly_check(input), "auto_classes" => autoclass::auto_classes(input), "check_clearance" => placetools::check_clearance(input),+ "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input),+ "find_space" => space::find_space(input), "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),
engine/src/space.rsadded+221@@ -0,0 +1,221 @@+//! Room on the board: free spots for a part near another (clear of every other part on that side, of that side's+//! copper, and inside the outline), and two-pad parts a quarter turn would shorten the airlines of. From the Altium+//! bridge's design.rs; a part is the box of its pads and, with the bodies companion, of its 3D body.+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);+fn grow(r: Rect, g: f64) -> Rect { (r.0 - g, r.1 - g, r.2 + g, r.3 + g) }+fn hits(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }++/// The parts' boxes and sides, with their bodies when the companion came.+fn part_boxes(st: &Value, input: &Value) -> BTreeMap<String, (bool, Rect)> {+ let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+ for b in crate::bodies::input_boxes(input).unwrap_or_default() {+ let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+ *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+ }+ crate::placement::part_boxes(st, &bodies)+}++/// find_space: input {board, bodies?, args: {near? | at?: {x, y}, part? | sizeMm?: [w, h], side?, clearanceMm?, edgeMm?,+/// count?, radiusMm?, copper?, check?}}. Free spots nearest first, as where the part's origin goes; with check: true,+/// the parts that already overlap or leave the outline.+pub fn find_space(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let s = |k: &str| args[k].as_str().map(String::from);+ let ring = crate::coppermap::outline_ring(&st);+ let boxes = part_boxes(&st, input);+ let parts: Vec<(String, Rect, bool)> = boxes.iter().map(|(r, (b, x))| (r.clone(), *x, *b)).collect();+ let clr = args["clearanceMm"].as_f64().unwrap_or(0.25);+ let edge = args["edgeMm"].as_f64().unwrap_or(0.3);+ let seg = |p: (f64, f64), a: (f64, f64), b: (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 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ dist(p, (a.0 + t * dx, a.1 + t * dy))+ };+ let in_outline = |r: Rect| -> bool {+ if ring.len() < 3 { return true; }+ [(r.0, r.1), (r.2, r.1), (r.2, r.3), (r.0, r.3)].iter().all(|&c| crate::route::inside(c, &ring) && (0..ring.len()).all(|i| seg(c, ring[i], ring[(i + 1) % ring.len()]) >= edge))+ };+ if args["check"].as_bool() == Some(true) {+ let mut overlaps = Vec::new();+ for i in 0..parts.len() { for j in i + 1..parts.len() {+ if parts[i].2 == parts[j].2 && hits(parts[i].1, parts[j].1) { overlaps.push(json!([parts[i].0, parts[j].0])); }+ } }+ let outside: Vec<&String> = parts.iter().filter(|p| !in_outline(p.1)).map(|p| &p.0).collect();+ return json!({"success": true, "overlaps": overlaps, "outsideOutline": outside, "parts": parts.len(),+ "_hint": "Overlaps compare part boxes (pads, and 3D bodies when given) on the same side; placement_check adds margins, stacked parts and mounting holes. find_space {near, part} finds a free spot; move_parts moves it."});+ }+ let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());+ // what to place: an existing part (its extent and origin) or a size+ let me = s("part").and_then(|p| parts.iter().find(|x| x.0.eq_ignore_ascii_case(&p)).cloned());+ let (w, h, off, my_bottom) = match (&me, args["sizeMm"].as_array()) {+ (Some((d, r, b)), _) => {+ let c = comp(d).unwrap_or(Value::Null);+ let (ox, oy) = (c["x"].as_f64().unwrap_or((r.0 + r.2) / 2.0), c["y"].as_f64().unwrap_or((r.1 + r.3) / 2.0));+ (r.2 - r.0, r.3 - r.1, (ox - (r.0 + r.2) / 2.0, oy - (r.1 + r.3) / 2.0), *b)+ }+ (None, Some(sz)) if sz.len() == 2 => (f(&sz[0]).max(0.01), f(&sz[1]).max(0.01), (0.0, 0.0), false),+ _ => return crate::input::refuse("bad_args", "part (a designator on the board) or sizeMm [w, h] is required"),+ };+ let bottom = match s("side").as_deref() { Some("bottom") => true, Some("top") => false, _ => my_bottom };+ let (cx, cy) = if let Some(n) = s("near") {+ match parts.iter().find(|x| x.0.eq_ignore_ascii_case(&n)) { Some((_, r, _)) => ((r.0 + r.2) / 2.0, (r.1 + r.3) / 2.0), None => return crate::input::refuse("no_such_part", &format!("{n} is not on the board")) }+ } else if args["at"].is_object() {+ (f(&args["at"]["x"]), f(&args["at"]["y"]))+ } else { return crate::input::refuse("bad_args", "near (a designator) or at {x, y} is required") };+ let mine = me.as_ref().map(|m| m.0.clone()).unwrap_or_default();+ let mut blocks: Vec<Rect> = parts.iter().filter(|p| p.2 == bottom && !p.0.eq_ignore_ascii_case(&mine)).map(|p| grow(p.1, clr)).collect();+ if args["copper"].as_bool() != Some(false) {+ let layer = if bottom { "B.Cu" } else { "F.Cu" };+ // net-less tracks are a hatched pour's pieces, which re-pour round a new part (6473 of them on the Bluetooth+ // Sentinel left no spot anywhere)+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == layer && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ let (a, b, hw) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0);+ blocks.push(grow((a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), hw + clr));+ }+ for v in st["vias"].as_array().into_iter().flatten() {+ let (x, y, r) = (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0);+ blocks.push(grow((x - r, y - r, x + r, y + r), clr));+ }+ }+ let count = args["count"].as_u64().unwrap_or(5) as usize;+ let radius = args["radiusMm"].as_f64().unwrap_or(10.0);+ let step = 0.25;+ let n = (radius / step).ceil() as i64;+ let mut cands: Vec<(f64, f64, f64)> = Vec::new();+ for i in -n..=n { for j in -n..=n {+ let (x, y) = (cx + i as f64 * step, cy + j as f64 * step);+ let d = dist((x, y), (cx, cy));+ if d <= radius { cands.push((d, x, y)); }+ } }+ cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+ let (mut spots, mut taken): (Vec<Value>, Vec<Rect>) = (Vec::new(), Vec::new());+ for (d, x, y) in cands {+ let r = (x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0);+ if blocks.iter().any(|b| hits(r, *b)) || taken.iter().any(|t| hits(r, *t)) || !in_outline(r) { continue; }+ spots.push(json!({"x": r3(x + off.0), "y": r3(y + off.1), "distanceMm": r3(d), "extent": [r3(r.0), r3(r.1), r3(r.2), r3(r.3)]}));+ taken.push(grow(r, clr));+ if spots.len() >= count { break; }+ }+ if spots.is_empty() { return json!({"success": false, "errorCode": "no_space", "error": format!("no free {} x {} mm spot within {radius} mm", r3(w), r3(h)), "spots": []}); }+ json!({"success": true, "spots": spots, "part": if mine.is_empty() { Value::Null } else { json!(mine) }, "sizeMm": [r3(w), r3(h)], "side": if bottom { "bottom" } else { "top" },+ "_hint": "spots[].x/y are where the part's origin goes: move_parts {parts:[{ref, x, y, rotation}]}, then repour (pours are not avoided: they clear round the new pads). Each spot keeps clearanceMm from other part boxes on that side, from that side's tracks and vias, and edgeMm from the outline; nearest first. find_space {check: true} lists parts that already overlap."})+}++/// Minimum spanning tree length of points (Prim's, O(n^2): nets are small).+fn mst(pts: &[(f64, f64)]) -> f64 {+ if pts.len() < 2 { return 0.0; }+ let (mut inside, mut best) = (vec![false; pts.len()], vec![f64::MAX; pts.len()]);+ best[0] = 0.0;+ let mut total = 0.0;+ for _ in 0..pts.len() {+ let mut u = usize::MAX;+ for i in 0..pts.len() { if !inside[i] && (u == usize::MAX || best[i] < best[u]) { u = i; } }+ inside[u] = true;+ total += best[u];+ for v in 0..pts.len() { if !inside[v] { let d = dist(pts[u], pts[v]); if d < best[v] { best[v] = d; } } }+ }+ total+}++/// check_orientation: input {board, args: {parts?, minImprovementMm?, planeNetPads?}}. Advisory: each two-pad part's+/// quarter turns scored in place, a routable net by its airline (MST) length with the pad there, a plane-like net+/// (more than planeNetPads pads, GND) by the distance to its nearest same-net pad. Turns that save minImprovementMm or+/// more are reported; nothing moves.+pub fn check_orientation(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let min_gain = args["minImprovementMm"].as_f64().unwrap_or(0.6);+ let plane_pads = args["planeNetPads"].as_u64().unwrap_or(12) as usize;+ let want: Vec<String> = args["parts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_ascii_uppercase())).collect()).unwrap_or_default();+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let mut net_pads: HashMap<String, Vec<(String, (f64, f64))>> = HashMap::new();+ for p in &pads {+ let n = p["net"].as_str().unwrap_or("");+ if !n.is_empty() { net_pads.entry(n.to_string()).or_default().push((p["ref"].as_str().unwrap_or("").to_string(), (f(&p["x"]), f(&p["y"])))); }+ }+ let (mut findings, mut checked) = (Vec::new(), 0);+ for c in st["components"].as_array().into_iter().flatten() {+ let r = c["ref"].as_str().unwrap_or("").to_string();+ if !want.is_empty() && !want.contains(&r.to_ascii_uppercase()) { continue; }+ let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(&r)).collect();+ if mine.len() != 2 { continue; }+ checked += 1;+ let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));+ // the airlines with the part turned by delta degrees (counterclockwise, y up)+ let score = |delta: f64| -> f64 {+ let a = delta.to_radians();+ let mut total = 0.0;+ for p in &mine {+ let net = p["net"].as_str().unwrap_or("");+ if net.is_empty() { continue; }+ let (px, py) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+ let at = (cx + px * a.cos() - py * a.sin(), cy + px * a.sin() + py * a.cos());+ let others: Vec<(f64, f64)> = net_pads.get(net).map(|v| v.iter().filter(|(o, _)| o != &r).map(|(_, q)| *q).collect()).unwrap_or_default();+ if others.is_empty() { continue; }+ total += if others.len() + 1 > plane_pads { others.iter().map(|q| dist(at, *q)).fold(f64::MAX, f64::min) } else { let mut pts = others.clone(); pts.push(at); mst(&pts) };+ }+ total+ };+ let now = score(0.0);+ let mut best = (0.0, now);+ for d in [90.0, 180.0, 270.0] { let sc = score(d); if sc < best.1 - 1e-9 { best = (d, sc); } }+ if now - best.1 >= min_gain {+ findings.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "airlineNowMm": r3(now), "airlineThenMm": r3(best.1), "savesMm": r3(now - best.1)}));+ }+ }+ findings.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));+ json!({"success": true, "checked": checked, "suggestions": findings,+ "_hint": "Advisory only: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there; distance to the nearest pad of a plane-like net such as GND). Turn with move_parts {parts:[{ref, rotation}]} (position kept), then reroute those nets. Routed copper is not considered: on a routed board, treat these as hints."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value {+ json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})+ }++ fn board() -> Value {+ // R1 lies along x, but its nets go up and down: a quarter turn shortens both airlines+ json!({"outline": [[0, 0], [20, 0], [20, 20], [0, 20]],+ "components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}, {"ref": "U1", "x": 10.0, "y": 15.0, "rotation": 0.0, "side": "top"}, {"ref": "U2", "x": 10.0, "y": 5.0, "rotation": 0.0, "side": "top"}],+ "pads": [pad("R1", "1", 9.0, 10.0, "A"), pad("R1", "2", 11.0, 10.0, "B"), pad("U1", "1", 10.0, 15.0, "A"), pad("U1", "2", 11.0, 15.0, "X"), pad("U2", "1", 10.0, 5.0, "B"), pad("U2", "2", 11.0, 5.0, "Y")]})+ }++ #[test]+ fn a_quarter_turn_that_shortens_the_airlines_is_suggested() {+ let r = check_orientation(&json!({"board": board(), "args": {}}));+ // U1 and U2 have two pads too, but turning them about the pad at their origin gains nothing+ assert_eq!((r["checked"].clone(), r["suggestions"].as_array().unwrap().len()), (json!(3), 1), "{r}");+ let s = &r["suggestions"][0];+ // pad 1 (net A) has to come up to U1: a quarter turn clockwise, 270 counterclockwise+ assert_eq!((s["ref"].as_str(), s["suggested"].as_f64()), (Some("R1"), Some(270.0)), "{r}");+ assert!(f(&s["savesMm"]) > 0.6);+ }++ #[test]+ fn free_spots_avoid_parts_copper_and_the_edge() {+ let mut b = board();+ b["tracks"] = json!([{"layer": "F.Cu", "net": "A", "width": 0.25, "start": {"x": 12.0, "y": 0.0}, "end": {"x": 12.0, "y": 20.0}}]);+ let r = find_space(&json!({"board": b, "args": {"near": "R1", "sizeMm": [2.0, 1.0], "count": 3}}));+ assert_eq!(r["success"], json!(true), "{r}");+ for s in r["spots"].as_array().unwrap() {+ let e: Vec<f64> = s["extent"].as_array().unwrap().iter().map(f).collect();+ // clear of R1's box (8.7..11.3 x 9.7..10.3) by 0.25, of the track at x 12 by 0.125 + 0.25+ assert!(e[2] <= 8.7 - 0.25 + 1e-9 || e[0] >= 11.3 + 0.25 - 1e-9 || e[3] <= 9.7 - 0.25 + 1e-9 || e[1] >= 10.3 + 0.25 - 1e-9, "{s}");+ assert!(e[2] <= 12.0 - 0.375 + 1e-9 || e[0] >= 12.0 + 0.375 - 1e-9, "{s}");+ }+ let chk = find_space(&json!({"board": board(), "args": {"check": true}}));+ assert_eq!((chk["overlaps"].as_array().unwrap().len(), chk["outsideOutline"].as_array().unwrap().len()), (0, 0));+ assert_eq!(find_space(&json!({"board": board(), "args": {"near": "Q9", "sizeMm": [1, 1]}}))["errorCode"], "no_such_part");+ }+}
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.16.0",+ "version": "0.17.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.16.0",+ "standard": "0.17.0", "reference": { "bridge": "altium", "version": "0.2.80"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.16.0",+ "standard": "0.17.0", "reference": { "bridge": "altium", "version": "0.2.80"