← Commit history

standard 0.19.0: engine 0.16.0 adds schematic_review and pin_pad_check

Colby Knox ·1f43002359 ·12d ago ·parent ed1dd33
9 files changed +252−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.19.0: the engine (0.16.0) adds `schematic_review` and `pin_pad_check` from the Altium bridge, reading the sheet parts+and netlist companions. Version 0.18.0: the engine (0.15.0) adds `route_pair` and `length_tune` from the Altium bridge, as plans in route_net arguments. On the Bluetooth Sentinel, `length_tune` lengthens IO_Red_LED6 by exactly 3.0 mm with every bump at the board's 0.1 mm from other copper (checked independently), and says when a net has no room.
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.15.0"+version = "0.16.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.15.0"+version = "0.16.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
@@ -23,6 +23,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 | | `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 | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs |@@ -30,6 +31,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) | | `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs |+| `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `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 |@@ -104,7 +106,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.15.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.16.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/lib.rs+4−1
@@ -24,6 +24,7 @@ pub mod pairroute; pub mod placement; pub mod placetools; pub mod route;+pub mod schreview; pub mod silkplace; pub mod space; pub mod writers;@@ -33,7 +34,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", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "route_pair", "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", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "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 {@@ -58,12 +59,14 @@ fn answer(check: &str, input: &Value) -> Value {         "impedance" => impedance::impedance(input),         "length_tune" => lengthtune::length_tune(input),         "net_lengths" => lengths::net_lengths(input),+        "pin_pad_check" => schreview::pin_pad_check(input),         "place_decoupling" => placetools::place_decoupling(input),         "plan_fanout" => placetools::plan_fanout(input),         "placement_check" => placement::placement_check(input),         "plan_route" => route::plan_route(input),         "return_path" => coppermap::return_path(input),         "route_pair" => pairroute::route_pair(input),+        "schematic_review" => schreview::schematic_review(input),         "silk_designators" => silkplace::silk_designators(input),         "thermal_vias" => features::thermal_vias(input),         "via_fence" => features::via_fence(input),
engine/src/schreview.rsadded+238
@@ -0,0 +1,238 @@+//! A schematic review before the board is updated: every placed pin from the sheets (with its electrical type) against+//! the compiled netlist, and the symbols' pins against the footprints' pads. From the Altium bridge's schreview.rs. Unconnected pins (power pins as errors), nets with one pin, outputs driving each+//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++/// Altium's pin electrical types (the ELECTRICAL field).+pub fn electrical(n: i64) -> &'static str {+    match n { 0 => "input", 1 => "io", 2 => "output", 3 => "open_collector", 4 => "passive", 5 => "hiz", 6 => "open_emitter", 7 => "power", _ => "unknown" }+}++/// A pin's type from the sheet model: Altium's code, or the name the standard uses (Fusion also sends `supply`, a+/// supply symbol's pin that drives its net, and `not_connected`).+pub fn pin_type(v: &Value) -> String {+    match v {+        Value::Number(n) => electrical(n.as_i64().unwrap_or(-1)).to_string(),+        Value::String(t) => match t.to_ascii_lowercase().replace(['-', ' '], "_").as_str() {+            "bidirectional" | "bidi" | "i/o" => "io".into(),+            "pwr" => "power".into(),+            "sup" => "supply".into(),+            "nc" => "not_connected".into(),+            "oc" | "opencollector" => "open_collector".into(),+            "pas" => "passive".into(),+            "tri" | "tristate" | "3state" => "hiz".into(),+            "in" => "input".into(),+            "out" => "output".into(),+            o => o.to_string(),+        },+        _ => "unknown".into(),+    }+}++pub fn is_ground(net: &str) -> bool {+    let n = net.to_ascii_uppercase();+    n == "GND" || n == "0V" || n == "VSS" || n == "AGND" || n == "DGND" || n == "PGND" || n == "GNDA" || n == "GNDD" || n.starts_with("GND_") || n.ends_with("_GND")+}++fn prefix(des: &str) -> String { des.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// The review, from the parts (read_sheet_file components: designator, pins with number/name/electrical) and the nets+/// ({net: ["U1.8", ...]}). Pure.+pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {+    // pin -> net+    let mut net_of: BTreeMap<String, String> = BTreeMap::new();+    for (n, ps) in nets { for p in ps.as_array().cloned().unwrap_or_default() { if let Some(s) = p.as_str() { net_of.insert(s.to_ascii_uppercase(), n.clone()); } } }+    // every placed pin, once (a multi-part chip appears once per part on the sheets)+    let mut pins: BTreeMap<String, (String, String, String, String)> = BTreeMap::new(); // key -> (designator, number, name, type)+    for c in parts {+        let des = c["designator"].as_str().unwrap_or("").to_string();+        if des.is_empty() || des.contains('?') { continue; }+        for p in c["pins"].as_array().cloned().unwrap_or_default() {+            let num = p["number"].as_str().unwrap_or("").to_string();+            pins.entry(format!("{des}.{num}").to_ascii_uppercase()).or_insert((des.clone(), num, p["name"].as_str().unwrap_or("").to_string(), pin_type(&p["electrical"])));+        }+    }+    let nc = |name: &str| { let n = name.to_ascii_uppercase(); n == "NC" || n == "N/C" || n == "DNC" || n.starts_with("NC_") || n == "NO CONNECT" };+    let mut errors = Vec::new();+    let mut warnings = Vec::new();+    for (key, (des, num, name, ty)) in &pins {+        if net_of.contains_key(key) || nc(name) || ty == "not_connected" { continue; }+        let f = json!({"check": "unconnected_pin", "pin": format!("{des}.{num}"), "name": name, "type": ty});+        if ty == "power" { errors.push(f) } else { warnings.push(f) }+    }+    for (n, ps) in nets {+        let members = ps.as_array().map(|a| a.len()).unwrap_or(0);+        if members == 1 { warnings.push(json!({"check": "single_pin_net", "net": n, "pin": ps[0]})); }+        let kinds: Vec<&str> = ps.as_array().cloned().unwrap_or_default().iter().filter_map(|p| p.as_str()).filter_map(|p| pins.get(&p.to_ascii_uppercase())).map(|t| t.3.as_str()).collect();+        let outs = kinds.iter().filter(|k| **k == "output").count();+        if outs >= 2 { errors.push(json!({"check": "outputs_together", "net": n, "outputs": outs})); }+        let driven = kinds.iter().any(|k| matches!(*k, "output" | "io" | "power" | "supply" | "passive" | "open_collector" | "open_emitter" | "hiz" | "unknown"));+        if !kinds.is_empty() && kinds.iter().all(|k| *k == "input") && !driven && members > 0 {+            warnings.push(json!({"check": "undriven_input", "net": n, "inputs": kinds.len()}));+        }+    }+    // decoupling: each IC power pin on a supply net (not ground) wants a capacitor between that net and a ground+    let caps_between: BTreeMap<String, Vec<String>> = {+        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+        let mut cap_nets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+        for (key, (des, ..)) in &pins {+            if prefix(des) == "C" { if let Some(n) = net_of.get(key) { cap_nets.entry(des.clone()).or_default().insert(n.clone()); } }+        }+        for (c, ns) in cap_nets {+            if ns.iter().any(|n| is_ground(n)) { for n in ns.iter().filter(|n| !is_ground(n)) { m.entry(n.clone()).or_default().push(c.clone()); } }+        }+        m+    };+    let mut supplies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for (key, (des, _, _, ty)) in &pins {+        if ty != "power" || !matches!(prefix(des).as_str(), "U" | "IC") { continue; }+        if let Some(n) = net_of.get(key).filter(|n| !is_ground(n)) { supplies.entry(n.clone()).or_default().insert(des.clone()); }+    }+    let mut decoupling = Vec::new();+    for (net, ics) in &supplies {+        let caps = caps_between.get(net).cloned().unwrap_or_default();+        // an inductor on a 'supply' pin's net makes it a switch node (a regulator's SW/LX pin): no capacitor belongs+        // there (the Bluetooth Sentinel's SMPS_LX read as missing decoupling)+        let switch = nets.get(net).and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|p| p.as_str()).any(|p| prefix(p.split('.').next().unwrap_or("")) == "L")).unwrap_or(false);+        let row = json!({"net": net, "ics": ics, "capacitors": caps, "ok": !caps.is_empty() || switch, "switchNode": switch});+        if caps.is_empty() && !switch { warnings.push(json!({"check": "no_decoupling", "net": net, "ics": ics})); }+        decoupling.push(row);+    }+    json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "decoupling": decoupling, "ok": errors.is_empty()})+}++/// Symbol pins against footprint pads, part by part: pins with no pad (a net that can never reach copper), pads with no+/// pin (copper no net reaches: fine for mounting and mechanical pads), and parts on only one side. Pure.+pub fn pin_pad(parts: &[Value], pads: &[Value], board_refs: &[String]) -> Value {+    let mut pins: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for c in parts {+        let des = c["designator"].as_str().unwrap_or("").to_string();+        if des.is_empty() || des.contains('?') { continue; }+        let e = pins.entry(des).or_default();+        for p in c["pins"].as_array().cloned().unwrap_or_default() { if let Some(n) = p["number"].as_str() { e.insert(n.to_ascii_uppercase()); } }+    }+    let mut padset: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for p in pads {+        let (Some(r), Some(n)) = (p["ref"].as_str().filter(|r| !r.is_empty()), p["name"].as_str()) else { continue };+        padset.entry(r.to_string()).or_default().insert(n.to_ascii_uppercase());+    }+    let mut mismatches = Vec::new();+    let mut ok = 0;+    for (des, ps) in &pins {+        let Some(pd) = padset.get(des) else { continue };+        let no_pad: Vec<&String> = ps.iter().filter(|n| !pd.contains(*n)).collect();+        // unnamed or '0' pads and mounting pads (MH*, EP*, SH*, MP*) carry no pin by design+        let no_pin: Vec<&String> = pd.iter().filter(|n| !ps.contains(*n) && !n.is_empty() && *n != "0" && !["MH", "MP", "SH", "EP", "PAD", "TAB"].iter().any(|m| n.starts_with(m))).collect();+        if no_pad.is_empty() && no_pin.is_empty() { ok += 1; continue; }+        mismatches.push(json!({"ref": des, "pinsWithoutPad": no_pad, "padsWithoutPin": no_pin, "pins": ps.len(), "pads": pd.len()}));+    }+    let on_board: BTreeSet<String> = board_refs.iter().cloned().collect();+    let sheet_only: Vec<&String> = pins.keys().filter(|d| !on_board.contains(*d) && !padset.contains_key(*d)).collect();+    let board_only: Vec<&String> = on_board.iter().filter(|d| !pins.contains_key(*d)).collect();+    json!({"partsChecked": ok + mismatches.len(), "matching": ok, "mismatches": mismatches, "onSheetsOnly": sheet_only, "onBoardOnly": board_only,+           "ok": mismatches.iter().all(|m| m["pinsWithoutPad"].as_array().map(|a| a.is_empty()).unwrap_or(true))})+}++/// The sheet parts companion (read_sheet_file's components, each with its pins): a list, or a reply with `components`.+fn sheet_parts(input: &Value) -> Option<Vec<Value>> {+    let p = crate::input::part(input, "sheetParts")?;+    p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned()+}++/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}} (or the map).+pub fn schematic_review(input: &Value) -> Value {+    let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+    let Some(n) = crate::input::part(input, "netlist") else { return crate::input::refuse("bad_args", "pass the compiled netlist ({nets: {net: [\"U1.8\", ...]}}) as netlist") };+    let nets = n.get("nets").or(Some(&n)).and_then(|x| x.as_object()).cloned().unwrap_or_default();+    let mut out = review(&parts, &nets);+    out["success"] = json!(true);+    out["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground). Pin types come from the symbols: a passive pin is never 'undriven'. Fix on the sheets (sch_connect, sch_place_part), then update_pcb.");+    out+}++/// pin_pad_check: input {board, sheetParts}. The symbols' pins against the placed footprints' pads.+pub fn pin_pad_check(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+    let refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)).collect();+    let mut out = pin_pad(&parts, st["pads"].as_array().map(|a| a.as_slice()).unwrap_or(&[]), &refs);+    out["success"] = json!(true);+    out["_hint"] = json!("pinsWithoutPad is a part whose footprint cannot carry every pin's net (a wrong footprint, or pin numbers that do not match): fix the footprint or the symbol (swap_footprint, build_footprint) before routing. padsWithoutPin is copper no net reaches. onSheetsOnly / onBoardOnly differ until update_pcb.");+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn part(des: &str, pins: &[(&str, &str, i64)]) -> Value {+        json!({"designator": des, "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()})+    }++    #[test]+    fn pin_types_come_as_codes_or_names() {+        assert_eq!(pin_type(&json!(7)), "power");+        assert_eq!(pin_type(&json!("Bidirectional")), "io");+        assert_eq!(pin_type(&json!("sup")), "supply");+        let parts = vec![json!({"designator": "U1", "pins": [+            {"number": "1", "name": "IN", "electrical": "input"}, {"number": "2", "name": "X", "electrical": "not_connected"}]}),+            json!({"designator": "P1", "pins": [{"number": "1", "name": "VCC", "electrical": "supply"}]})];+        let nets = json!({"VCC": ["U1.1", "P1.1"]});+        let r = review(&parts, nets.as_object().unwrap());+        assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");+    }++    #[test]+    fn the_review_finds_each_kind_of_problem() {+        let parts = vec![+            part("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0)]),+            part("U2", &[("1", "VDD", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "IN", 0)]),+            part("C1", &[("1", "", 4), ("2", "", 4)]),+            part("R1", &[("1", "", 4), ("2", "", 4)]),+            part("U3", &[("1", "SW", 7)]), part("L1", &[("1", "", 4), ("2", "", 4)]),+        ];+        let nets = json!({"3V3": ["U1.1", "C1.1"], "VDD": ["U2.1"], "LX": ["U3.1", "L1.1"], "GND": ["U1.2", "U2.2", "C1.2"], "BUS": ["U1.3", "U2.3"], "SENSE": ["U2.4"], "R": ["R1.1", "R1.2"]});+        let r = review(&parts, nets.as_object().unwrap());+        let checks = |k: &str| r[k].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap().to_string()).collect::<Vec<_>>();+        // U1.5 EN floats (input, warning); NC is skipped; two outputs share BUS (error)+        assert!(checks("errors").contains(&"outputs_together".to_string()));+        assert!(checks("warnings").contains(&"unconnected_pin".to_string()));+        assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.4"));+        // VDD has one pin and no capacitor; SENSE is an input nothing drives+        assert!(checks("warnings").contains(&"single_pin_net".to_string()));+        assert!(checks("warnings").contains(&"no_decoupling".to_string()));+        assert!(checks("warnings").contains(&"undriven_input".to_string()));+        let dec = r["decoupling"].as_array().unwrap();+        assert!(dec.iter().any(|d| d["net"] == "3V3" && d["ok"] == json!(true)));+        assert!(dec.iter().any(|d| d["net"] == "VDD" && d["ok"] == json!(false)));+        // a regulator's switch pin on an inductor is not a missing capacitor+        assert!(dec.iter().any(|d| d["net"] == "LX" && d["switchNode"] == json!(true) && d["ok"] == json!(true)));+    }+    #[test]+    fn pin_pad_finds_footprints_that_do_not_fit_their_symbols() {+        let parts = vec![part("U1", &[("1", "A", 0), ("2", "B", 0), ("3", "C", 0)]), part("R1", &[("1", "", 4), ("2", "", 4)]), part("C9", &[("1", "", 4), ("2", "", 4)])];+        let pad = |r: &str, n: &str| json!({"ref": r, "name": n});+        let pads = vec![pad("U1", "1"), pad("U1", "2"), pad("U1", "4"), pad("U1", "EP"), pad("R1", "1"), pad("R1", "2"), pad("J9", "1")];+        let r = pin_pad(&parts, &pads, &["U1".into(), "R1".into(), "J9".into()]);+        assert_eq!(r["matching"], 1);+        let m = &r["mismatches"][0];+        assert_eq!(m["ref"], "U1");+        assert_eq!(m["pinsWithoutPad"], json!(["3"]));+        assert_eq!(m["padsWithoutPin"], json!(["4"]));+        assert_eq!(r["onSheetsOnly"], json!(["C9"]));+        assert_eq!(r["onBoardOnly"], json!(["J9"]));+        assert_eq!(r["ok"], json!(false));+    }++    #[test]+    fn the_verbs_take_the_companions_bare_or_in_a_reply() {+        let parts = json!({"success": true, "components": [part("U1", &[("1", "VCC", 7), ("2", "GND", 7)]), part("C1", &[("1", "", 4), ("2", "", 4)])]});+        let r = schematic_review(&json!({"sheetParts": parts, "netlist": {"nets": {"3V3": ["U1.1", "C1.1"], "GND": ["U1.2", "C1.2"]}}}));+        assert_eq!((r["success"].clone(), r["ok"].clone(), r["pins"].clone()), (json!(true), json!(true), json!(4)), "{r}");+        assert_eq!(schematic_review(&json!({"netlist": {}}))["errorCode"], "bad_args");+        let b = json!({"components": [{"ref": "U1"}, {"ref": "C1"}], "pads": [{"ref": "U1", "name": "1"}, {"ref": "U1", "name": "2"}, {"ref": "C1", "name": "1"}, {"ref": "C1", "name": "2"}]});+        let p = pin_pad_check(&json!({"board": b, "sheetParts": parts}));+        assert_eq!((p["matching"].clone(), p["ok"].clone()), (json!(2), json!(true)), "{p}");+    }+}
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.18.0",+  "version": "0.19.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.18.0",+ "standard": "0.19.0",  "reference": {   "bridge": "altium",   "version": "0.2.80"
verbs/standard.json+1−1
@@ -1,5 +1,5 @@ {- "standard": "0.18.0",+ "standard": "0.19.0",  "reference": {   "bridge": "altium",   "version": "0.2.80"