← Commit history

standard 0.12.0: engine 0.9.0 adds estimate_cost, prices from a dated record

Colby Knox ·a75b8c9f40 ·13d ago ·parent 4ad294c
10 files changed +200−11
README.md+3
@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.12.0: the engine (0.9.0) adds `estimate_cost` from the KiCad bridge's fab.rs, without its hard-coded rates: prices come+in as a record read from the fab's own page with its date (`prices`), and without one the reply is the board's numbers.+On the Bluetooth Sentinel those numbers match the Altium bridge's own fab_check. 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.
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.8.0"+version = "0.9.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.8.0"+version = "0.9.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,6 +14,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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. From the KiCad bridge's place_tools | | `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 | | `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 |@@ -62,6 +63,7 @@ Every check takes one JSON object: | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |+| `prices` | a price record read from the fab's own page: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w, h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint, perThtJoint, perExtendedPart}, excludes?}`. The pcb row used is the one for the fewest copper layers that covers the board | | `args` | the check's own arguments, as the standard verb documents them |  Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.@@ -93,7 +95,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.8.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.9.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/cost.rsadded+182
@@ -0,0 +1,182 @@+//! The numbers a fab quote needs, from the board model (size, copper layers, thickness, smallest track, gap and drill,+//! holes, parts and joints), and a rough price table at the quantities asked. From the KiCad bridge's fab.rs, with one+//! change: the engine has no rates of its own. Prices come in as a record read from the fab's own page with its date+//! (`prices`), and without one the reply is the numbers only. Pure.+use serde_json::{json, Value};+use std::collections::BTreeSet;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The board's numbers. `layers` from the stack when given, else the copper layers the board uses; the thickness from+/// the stack only.+pub fn board_numbers(st: &Value, stack: Option<&Value>, rules: &[Value]) -> Value {+    let (w, h) = crate::geom::outline_bbox(st).map(|(x0, y0, x1, y1)| (x1 - x0, y1 - y0)).unwrap_or((0.0, 0.0));+    let mut used: BTreeSet<String> = BTreeSet::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(l) = x["layer"].as_str().filter(|l| l.ends_with(".Cu") && *l != "*.Cu") { used.insert(l.to_string()); }+        }+    }+    let layers = stack.and_then(|s| s["copperLayers"].as_u64()).unwrap_or((used.len() as u64).max(2));+    let thickness = stack.and_then(|s| s["totalUm"].as_f64()).filter(|t| *t > 0.0).map(|t| r3(t / 1000.0));+    let m = crate::fab::measure(st);+    let mm = |k: &str| m.get(k).and_then(|v| v["mm"].as_f64());+    let min_drill = [mm("viaDrill"), mm("padHole")].into_iter().flatten().fold(None, |a: Option<f64>, b| Some(a.map(|x| x.min(b)).unwrap_or(b)));+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let vias = st["vias"].as_array().map(|a| a.len()).unwrap_or(0);+    let drilled = pads.iter().filter(|p| f(&p["drill"]) > 0.0).count();+    // a part is a component with pads (a logo or a drawing has none); through-hole when any pad is drilled+    let (mut smd, mut tht, mut smd_j, mut tht_j, mut sides, mut unique) = (0, 0, 0, 0, BTreeSet::new(), BTreeSet::new());+    for c in st["components"].as_array().into_iter().flatten() {+        let r = c["ref"].as_str().unwrap_or("");+        let own: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(r)).collect();+        if own.is_empty() { continue; }+        let holes = own.iter().filter(|p| f(&p["drill"]) > 0.0).count();+        if holes > 0 { tht += 1; } else { smd += 1; }+        smd_j += own.len() - holes;+        tht_j += holes;+        sides.insert(c["side"].as_str().unwrap_or("top").to_string());+        unique.insert(c["pattern"].as_str().unwrap_or(r).to_string());+    }+    json!({"widthMm": r3(w), "heightMm": r3(h), "areaMm2": r2(w * h), "layers": layers, "thicknessMm": thickness,+        "minTrackMm": mm("track"), "minGapMm": crate::fab::min_clearance(rules), "minDrillMm": min_drill,+        "holes": vias + drilled, "vias": vias, "drilledPads": drilled, "smdParts": smd, "thtParts": tht, "totalParts": smd + tht,+        "smdJoints": smd_j, "thtJoints": tht_j, "uniqueParts": unique.len(), "assemblySides": sides.into_iter().collect::<Vec<_>>()})+}++/// The price table from a price record: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w,+/// h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint,+/// perThtJoint, perExtendedPart}, excludes?: [..]}`. The pcb row is the one for the fewest copper layers that still+/// covers the board.+pub fn price_table(n: &Value, prices: &Value, quantities: &[u64], assembly: bool, basic_parts: Option<u64>) -> Result<Value, String> {+    let layers = n["layers"].as_u64().unwrap_or(2);+    let rows = prices["pcb"].as_array().ok_or("the price record has no pcb rows")?;+    let row = rows.iter().filter(|r| r["layers"].as_u64().map(|l| l >= layers).unwrap_or(false)).min_by_key(|r| r["layers"].as_u64())+        .ok_or_else(|| format!("the price record has no row for {layers} copper layers"))?;+    let (setup, per_m2) = (f(&row["setup"]), f(&row["perM2"]));+    let (w, h) = (f(&n["widthMm"]), f(&n["heightMm"]));+    let area_m2 = w * h / 1_000_000.0;+    let promo = row.get("promo").filter(|p| {+        let max = (f(&p["maxMm"][0]), f(&p["maxMm"][1]));+        (w <= max.0 && h <= max.1) || (w <= max.1 && h <= max.0)+    });+    let factor = match (prices.get("thickness"), n["thicknessMm"].as_f64()) {+        (Some(t), Some(mm)) if (mm - f(&t["standardMm"])).abs() > 0.05 => f(&t["factor"]).max(1.0),+        _ => 1.0,+    };+    let a = &prices["assembly"];+    if assembly && a.is_null() { return Err("assembly was asked for, but the price record has no assembly rates".into()); }+    let sides = n["assemblySides"].as_array().map(|s| s.len()).unwrap_or(1).max(1) as f64;+    let unique = n["uniqueParts"].as_u64().unwrap_or(0);+    let extended = basic_parts.map(|b| unique.saturating_sub(b)).unwrap_or(unique);+    let mut out = Vec::new();+    for &q in quantities {+        let qf = q as f64;+        // the promotional price for the smallest quantity it covers+        let promo_price = promo.and_then(|p| p["prices"].as_array().and_then(|ps| ps.iter().filter(|e| e[0].as_u64().map(|x| x >= q).unwrap_or(false)).min_by_key(|e| e[0].as_u64()).map(|e| f(&e[1]))));+        let pcb = r2(promo_price.unwrap_or(setup + per_m2 * area_m2 * qf) * factor);+        let mut r = json!({"quantity": q, "board": pcb});+        let mut total = pcb;+        if assembly && n["totalParts"].as_u64().unwrap_or(0) > 0 {+            let asm = r2((f(&a["setupPerSide"]) + f(&a["stencilPerSide"])) * sides + (f(&n["smdJoints"]) * f(&a["perSmdJoint"]) + f(&n["thtJoints"]) * f(&a["perThtJoint"])) * qf + f(&a["perExtendedPart"]) * extended as f64);+            r["assembly"] = json!(asm);+            total += asm;+        }+        r["total"] = json!(r2(total));+        r["perBoard"] = json!(r2(total / qf.max(1.0)));+        r["currency"] = prices["currency"].clone();+        out.push(r);+    }+    let mut assumptions = vec![format!("Board: {setup} setup plus {per_m2} per square metre of boards, from the {}-layer row.", row["layers"])];+    if let Some(p) = promo { assumptions.push(format!("Boards up to {} x {} mm take the promotional prices {} where they cover the quantity.", p["maxMm"][0], p["maxMm"][1], p["prices"])); }+    if factor > 1.0 { assumptions.push(format!("A thickness other than {} mm multiplies the board price by {factor}.", prices["thickness"]["standardMm"])); }+    if assembly {+        assumptions.push(format!("Assembly: setup and stencil per assembled side ({sides} side(s)), per joint, and a loading fee per extended part ({extended} of {unique} unique parts counted as extended{}).", if basic_parts.is_some() { "" } else { "; pass basicParts to count the basic ones" }));+        assumptions.push("Component cost is not included: price the BOM with adom-parts-search.".into());+    }+    for e in prices["excludes"].as_array().into_iter().flatten() { if let Some(s) = e.as_str() { assumptions.push(format!("Not included: {s}.")); } }+    Ok(json!({"prices": out, "assumptions": assumptions}))+}++/// estimate_cost: input {board, stack?, rules?, prices?, args: {quantities?, assembly?, basicParts?}}.+pub fn estimate_cost(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let quantities: Vec<u64> = args["quantities"].as_array().map(|a| a.iter().filter_map(|v| v.as_u64()).filter(|q| *q > 0).collect()).unwrap_or_else(|| vec![5, 10, 30, 100]);+    if quantities.is_empty() { return crate::input::refuse("bad_args", "quantities are whole numbers above 0, e.g. [5, 10, 30, 100]"); }+    let assembly = args["assembly"].as_bool().unwrap_or(false);+    let stack = crate::input::stack(input);+    let n = board_numbers(&st, stack.as_ref(), &crate::input::rules(input));+    let mut notes = Vec::new();+    if n["thicknessMm"].is_null() { notes.push("thicknessMm: no stack in the input, so the thickness is unknown and no thickness surcharge is applied"); }+    if n["minGapMm"].is_null() { notes.push("minGapMm: the board's clearance rule, and no rules came in the input"); }+    let Some(prices) = crate::input::part(input, "prices") else {+        return json!({"success": true, "board": n, "prices": Value::Null, "source": Value::Null, "notes": notes, "quantities": quantities,+            "_hint": "The board's numbers only: the engine carries no rates. Pass a price record as prices ({vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?}], thickness?, assembly?, excludes?}), read from the fab's own page with the date, and the reply prices these quantities from it."});+    };+    match price_table(&n, &prices, &quantities, assembly, args["basicParts"].as_u64()) {+        Ok(t) => json!({"success": true, "board": n, "prices": t["prices"], "assumptions": t["assumptions"], "notes": notes, "rough": true,+            "source": {"name": prices["vendor"], "url": prices["url"], "read": prices["read"]},+            "_hint": "A rough estimate from the price record named in source, as of its read date: get a real quote from the fab before ordering."}),+        Err(e) => crate::input::refuse("bad_prices", &e),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outline": [[0, 0], [50, 0], [50, 40], [0, 40]],+            "tracks": [{"layer": "F.Cu", "width": 0.2, "net": "A", "start": {"x": 1, "y": 1}, "end": {"x": 5, "y": 1}}, {"layer": "B.Cu", "width": 0.15, "net": "A", "start": {"x": 1, "y": 2}, "end": {"x": 5, "y": 2}}],+            "vias": [{"drill": 0.3, "size": 0.6, "net": "A", "position": {"x": 5, "y": 1.5}}],+            "components": [{"ref": "U1", "side": "top", "pattern": "SOIC8"}, {"ref": "J1", "side": "top", "pattern": "HDR2"}, {"ref": "C1", "side": "bottom", "pattern": "0603"}, {"ref": "LOGO", "side": "top", "pattern": "LOGO"}],+            "pads": [{"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 10, "y": 10, "sizeX": 1.7, "sizeY": 1.7},+                     {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 12.54, "y": 10, "sizeX": 1.7, "sizeY": 1.7}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}]})+    }++    fn record() -> Value {+        json!({"vendor": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29", "currency": "USD",+            "pcb": [{"layers": 2, "setup": 2.0, "perM2": 30.0, "promo": {"maxMm": [100, 100], "prices": [[5, 2.0], [10, 5.0]]}}, {"layers": 4, "setup": 8.0, "perM2": 90.0}],+            "thickness": {"standardMm": 1.6, "factor": 1.15},+            "assembly": {"setupPerSide": 8.0, "stencilPerSide": 7.0, "perSmdJoint": 0.0017, "perThtJoint": 0.0173, "perExtendedPart": 3.0},+            "excludes": ["shipping", "tax"]})+    }++    #[test]+    fn the_numbers_come_from_the_board() {+        let n = board_numbers(&board(), None, &[json!({"kind": "clearance", "clearanceMm": 0.15})]);+        assert_eq!((n["widthMm"].as_f64(), n["heightMm"].as_f64(), n["layers"].as_u64()), (Some(50.0), Some(40.0), Some(2)));+        assert_eq!((n["minTrackMm"].as_f64(), n["minGapMm"].as_f64(), n["minDrillMm"].as_f64()), (Some(0.15), Some(0.15), Some(0.3)));+        // the logo has no pads: three parts, J1 through-hole, two sides+        assert_eq!((n["smdParts"].as_u64(), n["thtParts"].as_u64(), n["smdJoints"].as_u64(), n["thtJoints"].as_u64(), n["holes"].as_u64()), (Some(2), Some(1), Some(4), Some(2), Some(3)));+        assert_eq!(n["assemblySides"], json!(["bottom", "top"]));+        assert!(n["thicknessMm"].is_null());+        assert_eq!(board_numbers(&board(), Some(&json!({"copperLayers": 4, "totalUm": 1600})), &[])["thicknessMm"], json!(1.6));+    }++    #[test]+    fn without_a_price_record_it_answers_the_numbers_only() {+        let r = estimate_cost(&json!({"board": board(), "args": {}}));+        assert_eq!(r["success"], json!(true));+        assert!(r["prices"].is_null() && r["source"].is_null());+        assert_eq!(r["board"]["layers"], json!(2));+    }++    #[test]+    fn prices_come_from_the_record_and_name_it() {+        let r = estimate_cost(&json!({"board": board(), "prices": record(), "args": {"quantities": [5, 30], "assembly": true}}));+        assert_eq!(r["source"], json!({"name": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29"}));+        // 5 boards: the promotional 2.00; 30: 2 + 30 * 0.002 m2 * 30 = 3.80+        assert_eq!((r["prices"][0]["board"].as_f64(), r["prices"][1]["board"].as_f64()), (Some(2.0), Some(3.8)), "{r}");+        // assembly at 5: (8 + 7) * 2 sides + (4 * 0.0017 + 2 * 0.0173) * 5 + 3 * 3 unique = 39.21+        assert_eq!(r["prices"][0]["assembly"].as_f64(), Some(39.21), "{r}");+        assert!(r["assumptions"].as_array().unwrap().iter().any(|a| a == "Not included: shipping."));+        // a 4-layer stack takes the 4-layer row; 6 layers has none+        let four = estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 4, "totalUm": 1200}, "prices": record(), "args": {"quantities": [10]}}));+        assert_eq!(four["prices"][0]["board"].as_f64(), Some(r2((8.0 + 90.0 * 0.002 * 10.0) * 1.15)), "{four}");+        assert_eq!(estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 6}, "prices": record(), "args": {}}))["errorCode"], "bad_prices");+    }+}
engine/src/lib.rs+4−2
@@ -4,11 +4,12 @@ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value;  pub mod bodies; pub mod coppermap;+pub mod cost; pub mod current; pub mod fab; pub mod features;@@ -25,7 +26,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_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "estimate_cost", "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 {@@ -40,6 +41,7 @@ fn answer(check: &str, input: &Value) -> Value {         "check_clearance" => placetools::check_clearance(input),         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),+        "estimate_cost" => cost::estimate_cost(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),
engine/src/main.rs+3−3
@@ -1,5 +1,5 @@ //! adom-eda-checks <check> [bundle.json | -]-//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}'] //! adom-eda-checks import_svg --svg art.svg --args '{"at": [10, 10], "widthMm": 20}' //! Prints the check's reply as JSON. `adom-eda-checks list` names the checks. use serde_json::{json, Value};@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> {     while let Some(flag) = it.next() {         let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;         match flag.as_str() {-            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--sheet-parts" | "--netlist" => {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" => {                 let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };                 b[key] = read(val)?;             }@@ -39,7 +39,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> { fn main() {     let argv: Vec<String> = std::env::args().skip(1).collect();     let out = match argv.first().map(String::as_str) {-        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),+        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some(check) => match bundle(&argv[1..]) {             Ok(b) => adom_eda_checks::run(check, &b),
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.11.0",+  "version": "0.12.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.11.0",+ "standard": "0.12.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.11.0",+ "standard": "0.12.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"