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.30.0: engine 0.24.0 (bom and cross_check)
9 files changed
+306−6
README.md+1@@ -158,6 +158,7 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.30.0: engine 0.24.0 answers `bom` (grouped by part number, a variant applied, order checks, the JLCPCB, Mouser and DigiKey files as text; from the Altium bridge) and `cross_check` (the sheets' designators against the board's), so every bridge can answer them exactly from the parts it already reads. Version 0.29.6: the reference is Altium bridge 0.2.87 (engine 0.23.0, sends sheetDrawing). Version 0.29.5: coverage reads Fusion 360 bridge 1.13.34 (five partials now exact: board_summary, read_schematic, list_library, export_pos, window_info). Version 0.29.4: coverage reads Fusion 360 bridge 1.13.33 (239 of 252: panel now real; export_3d, import_step and tool_update exact).
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.23.0"+version = "0.24.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.23.0"+version = "0.24.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@@ -14,6 +14,7 @@ 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 | | `board_report` | board, and every companion it can use | every check the engine has, run on this bundle, as one self-contained HTML page (`text`), dark by default: fab limits (with `fab`), JLCPCB assembly, schematic review and symbols against footprints (with `sheetParts` and `netlist`), impedance, return paths and copper balance (with `stack`), 3D heights (with `bodies`), the design review, and the tool's own DRC (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them; `args` carries `lcscMissing`, `panelMm`, `gridMm` and `alwaysChecked` to those checks. A section whose input is missing says which to pass. From the Altium bridge's report.rs |+| `bom` | sheetParts, board? | the bill of materials: fitted parts grouped one line per manufacturer part number (natural designator order), a variant applied (`args.variant`: notFitted, alternates), checks for what an order needs (missing MPN, maker or Mouser/DigiKey/LCSC numbers, lines mixing values, JLCPCB lines without LCSC, DNP, excluded and not-purchased parts, sheet and board mismatches), and the order files as text (`files[]`: bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv); from the Altium bridge's bom.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) |@@ -22,6 +23,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 (and, with bodies, those `stacked` under a raised one) or have pads off the outline, as placement_check judges them. 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 |+| `cross_check` | sheetParts, board | the sheets' designators against the board's: matched, on the sheets but not the board, on the board but on no sheet | | `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), a shorter column spread over the longer one's span (SOT-23-5's pins 4 and 5 level with 3 and 1), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` round the pads and body 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 |
engine/src/bom.rsadded+294@@ -0,0 +1,294 @@+//! The bill of materials, from the sheets' parts and the board: fitted parts grouped one line per manufacturer part+//! number, checked for what an order needs, and the order files as text (generic, JLCPCB BOM + CPL, Mouser and DigiKey+//! uploads). Ported from the Altium bridge's bom.rs so every bridge answers `bom` the same way; the bridge reads the+//! parts and writes the files. Also `cross_check`: the sheets' designators against the board's.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++/// Parameter names that mean the same thing across libraries and tools (Altium templates, the Adom part import, the+/// wiki's Cloud Library, Altium 365 supplier links, KiCad fields, EAGLE attributes).+const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "MANUFACTURER_PN", "Part Number", "PartNumber", "Part_Number"];+const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER", "MANUFACTURER_NAME"];+const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];+const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];+const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #", "JLCPCB"];+const VALUE: &[&str] = &["Value", "VALUE"];+const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];+const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];+const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];+const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];++fn st(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => String::new(), o => o.to_string() } }++/// R1 < R2 < R10 < R10A: the letters, then the number, then the rest.+pub fn natural_key(d: &str) -> (String, u64, String) {+ let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();+ let rest = &d[pre.len()..];+ let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+ (pre.to_ascii_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())+}++fn truthy(v: &str) -> bool { matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x") }++/// One part, merged from its sheet (parameters) and the board (footprint, placement).+#[derive(Debug, Clone, Default)]+pub struct Part {+ pub designator: String,+ pub comment: String,+ pub description: String,+ pub lib_ref: String,+ /// out of the BOM by its kind (Altium's graphical, net tie and standard-no-BOM kinds; KiCad's exclude from BOM)+ pub no_bom: bool,+ pub params: Vec<(String, String)>,+ pub footprint: String,+ pub placed: Option<(f64, f64, f64, String)>,+ pub dnp: bool,+ pub alternate: String,+ pub managed: bool,+ pub source_lib: String,+}++impl Part {+ fn get(&self, names: &[&str]) -> String {+ for n in names {+ if let Some((_, v)) = self.params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) { return v.trim().to_string(); }+ }+ String::new()+ }+ fn mpn(&self) -> String { self.get(MPN) }+ fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }+ /// Copper test pads, fiducials and the like carry an MPN or maker that says nothing is bought ("none (PCB copper)"):+ /// they stay in the BOM, marked, and out of the ordering and assembly files.+ fn not_purchased(&self) -> bool {+ [self.mpn(), self.get(MFR)].iter().any(|v| {+ let m = v.trim().to_ascii_lowercase();+ m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")+ })+ }+}++/// The parts from the sheetParts companion (units of one part merged by designator), with the board's footprint and+/// placement. Returns (parts, designators on the board but on no sheet).+pub fn parts(sheet: &[Value], board: Option<&Value>) -> (Vec<Part>, Vec<String>) {+ let mut by: BTreeMap<String, Part> = BTreeMap::new();+ for c in sheet {+ let d = st(&c["designator"]);+ if d.is_empty() || d.ends_with('?') { continue; }+ // params: [{name, value}] or {name: value}+ let params: Vec<(String, String)> = match &c["params"] {+ Value::Array(a) => a.iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect(),+ Value::Object(o) => o.iter().map(|(k, v)| (k.clone(), st(v))).collect(),+ _ => Vec::new(),+ };+ let kind = c["kind"].as_i64().unwrap_or(0);+ let no_bom = matches!(kind, 2 | 4 | 5) || c["inBom"] == false || c["excludeFromBom"] == true;+ let e = by.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {+ designator: d.clone(), comment: [&c["comment"], &c["value"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),+ description: st(&c["description"]), lib_ref: [&c["libRef"], &c["libReference"], &c["libId"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),+ no_bom, managed: !st(&c["itemGuid"]).is_empty(), source_lib: st(&c["sourceLibrary"]), dnp: c["dnp"] == true, ..Default::default() });+ // a multi-unit part appears once per unit: keep the first, add what it lacks+ for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }+ if c["dnp"] == true { e.dnp = true; }+ if e.footprint.is_empty() { e.footprint = [&c["footprint"], &c["pattern"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(); }+ }+ let mut board_only = Vec::new();+ if let Some(b) = board {+ for c in b["components"].as_array().cloned().unwrap_or_default() {+ let d = st(&c["ref"]);+ if d.is_empty() { continue; }+ let placed = Some((c["x"].as_f64().unwrap_or(0.0), c["y"].as_f64().unwrap_or(0.0), c["rotation"].as_f64().unwrap_or(0.0), if c["side"] == "bottom" { "bottom".to_string() } else { "top".to_string() }));+ match by.get_mut(&d.to_ascii_uppercase()) {+ Some(p) => { let pat = st(&c["pattern"]); if !pat.is_empty() { p.footprint = pat; } p.placed = placed; }+ None => board_only.push(d),+ }+ }+ }+ for p in by.values_mut() {+ if truthy(&p.get(DNP)) || p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }+ }+ board_only.sort_by_key(|d| natural_key(d));+ (by.into_values().collect(), board_only)+}++/// Group the fitted parts: one line per manufacturer part number, else per value + footprint. Returns (lines, checks).+pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {+ let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();+ for p in parts.iter().filter(|p| !p.dnp && !p.no_bom) {+ let m = p.mpn();+ let key = if m.is_empty() { format!("~{}|{}", p.value().to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };+ groups.entry(key).or_default().push(p);+ }+ let mut lines = Vec::new();+ let mut inconsistent = Vec::new();+ for (_, mut g) in groups {+ g.sort_by_key(|p| natural_key(&p.designator));+ let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();+ let mut line = Map::new();+ line.insert("quantity".into(), json!(g.len()));+ line.insert("designators".into(), json!(refs));+ for (name, names) in FIELDS {+ let mut distinct: Vec<String> = g.iter().map(|p| p.get(names)).filter(|v| !v.is_empty()).collect();+ distinct.sort(); distinct.dedup();+ if distinct.len() > 1 { inconsistent.push(json!({"field": name, "designators": refs, "values": distinct})); }+ line.insert((*name).into(), json!(distinct.first().cloned().unwrap_or_default()));+ }+ let first = g[0];+ let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();+ fps.sort(); fps.dedup();+ if fps.len() > 1 { inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps})); }+ line.insert("value".into(), json!(first.value()));+ line.insert("comment".into(), json!(first.comment));+ line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));+ line.insert("sourceLibrary".into(), json!(first.source_lib));+ line.insert("managed".into(), json!(first.managed));+ line.insert("footprint".into(), json!(fps.first().copied().unwrap_or("")));+ line.insert("package".into(), json!(first.get(PACKAGE)));+ line.insert("libReference".into(), json!(first.lib_ref));+ line.insert("purchase".into(), json!(!first.not_purchased()));+ lines.push(Value::Object(line));+ }+ lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));+ let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };+ let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased()).collect();+ let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());+ let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && st(&l["lcsc"]).is_empty())+ .map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();+ let checks = json!({+ "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),+ "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},+ "inconsistent": inconsistent,+ "dnp": sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect()),+ "excludedKinds": sorted(parts.iter().filter(|p| !p.dnp && p.no_bom).map(|p| p.designator.clone()).collect()),+ "notPurchased": sorted(parts.iter().filter(|p| !p.dnp && !p.no_bom && p.not_purchased()).map(|p| p.designator.clone()).collect()),+ "jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},+ });+ (lines, checks)+}++fn csv(rows: &[Vec<String>]) -> String {+ let cell = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };+ rows.iter().map(|r| r.iter().map(|c| cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"+}++/// The order files as (name, text). `boards` multiplies the quantities of the distributor uploads.+pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {+ let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();+ let mut out = Vec::new();+ let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];+ for (i, l) in lines.iter().enumerate() {+ rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);+ }+ out.push(("bom.csv".to_string(), csv(&rows)));+ // JLCPCB assembly: their BOM and CPL columns, mm, Top/Bottom+ let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];+ for l in lines.iter().filter(|l| l["purchase"] != json!(false)) { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }+ out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));+ let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];+ let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased() && p.placed.is_some()).collect();+ placed.sort_by_key(|p| natural_key(&p.designator));+ for p in placed {+ let (x, y, r, side) = p.placed.clone().unwrap();+ c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);+ }+ out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));+ // the distributors' BOM tools (Mouser BOM Tool, DigiKey myLists) map these headers directly+ for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {+ let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];+ for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) {+ m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);+ }+ out.push((name.to_string(), csv(&m)));+ }+ out+}++fn sheet_list(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()+}++/// bom: input {sheetParts, board?, args: {variant? (the standard `variants` row: {name, notFitted: [ref], alternates:+/// [{designator, part | fields}]}), boards? (1)}}. Parts carry designator, comment or value, description, libRef,+/// footprint?, kind? (Altium) or inBom/excludeFromBom (KiCad), dnp?, params ([{name, value}] or {name: value}).+pub fn bom(input: &Value) -> Value {+ let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (every sheet, with their parameters) as sheetParts") };+ let board = crate::input::part(input, "board");+ let (mut list, board_only) = parts(&sheet, board.as_ref());+ let args = &input["args"];+ let variant = &args["variant"];+ let mut alternates = Vec::new();+ if variant.is_object() {+ let nf: BTreeSet<String> = variant["notFitted"].as_array().map(|a| a.iter().map(|x| st(x).to_ascii_uppercase()).collect()).unwrap_or_default();+ let alts: BTreeMap<String, String> = match &variant["alternates"] {+ Value::Array(a) => a.iter().map(|x| (st(&x["designator"]).to_ascii_uppercase(), if x["part"].is_null() { st(&x["fields"]) } else { st(&x["part"]) })).collect(),+ Value::Object(o) => o.iter().map(|(k, v)| (k.to_ascii_uppercase(), st(v))).collect(),+ _ => BTreeMap::new(),+ };+ for p in list.iter_mut() {+ let k = p.designator.to_ascii_uppercase();+ if nf.contains(&k) { p.dnp = true; }+ if let Some(a) = alts.get(&k) { p.alternate = a.clone(); alternates.push(json!({"designator": p.designator, "alternatePart": a})); }+ }+ }+ let (lines, mut checks) = group(&list);+ let mut not_on_board: Vec<String> = if board.is_some() { list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect() } else { Vec::new() };+ not_on_board.sort_by_key(|d| natural_key(d));+ checks["sheetNotOnBoard"] = json!(not_on_board);+ checks["boardNotOnSheet"] = json!(board_only);+ let boards = args["boards"].as_u64().unwrap_or(1);+ let fitted = list.iter().filter(|p| !p.dnp && !p.no_bom).count();+ let files: Vec<Value> = files(&lines, &list, boards).into_iter().map(|(n, t)| json!({"name": n, "text": t})).collect();+ json!({"success": true, "variant": variant["name"], "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks, "alternates": alternates, "boards": boards, "files": files,+ "_hint": "One line per manufacturer part number. Write files[] (bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv for JLCPCB assembly, bom-mouser.csv and bom-digikey.csv for those sites' BOM tools). checks: parts with no MPN or distributor number (find them with adom-parts-search and write them back with sheet_params), lines that mix values for one part number (checks.inconsistent), JLCPCB lines with no LCSC number, and parts on the sheets but not the board or the other way round."})+}++/// cross_check: input {sheetParts, board}. The sheets' designators against the board's.+pub fn cross_check(input: &Value) -> Value {+ let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts as sheetParts") };+ let st_ = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let (list, board_only) = parts(&sheet, Some(&st_));+ let mut sheet_only: Vec<String> = list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect();+ sheet_only.sort_by_key(|d| natural_key(d));+ let matched = list.iter().filter(|p| p.placed.is_some()).count();+ json!({"success": true, "schematicComponents": list.len(), "boardComponents": matched + board_only.len(), "matched": matched,+ "onSheetNotOnBoard": sheet_only, "onBoardNotOnSheet": board_only,+ "_hint": "onSheetNotOnBoard: update the board from the schematic (update_pcb). onBoardNotOnSheet: parts placed by hand or left after a schematic change; remove them (remove_parts) or add them to a sheet. Parts kept out of the BOM by kind are not counted as missing from the board."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn the_bom_groups_by_part_number_and_checks_the_order() {+ let p = |d: &str, v: &str, params: Value| json!({"designator": d, "comment": v, "libRef": "R", "params": params});+ let sheet = vec![+ p("R10", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98220"})),+ p("R2", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98221"})),+ p("R1", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo"})),+ p("C1", "100n", json!({"Value": "100nF"})),+ p("TP1", "TP", json!({"MPN": "none (PCB copper)"})),+ p("U1", "MCU", json!([{"name": "MPN", "value": "RP2040"}, {"name": "DNP", "value": "yes"}])),+ json!({"designator": "R?", "comment": "x"}),+ json!({"designator": "H1", "comment": "hole", "excludeFromBom": true}),+ ];+ let board = json!({"components": [{"ref": "R1", "pattern": "R_0603", "x": 1.0, "y": 2.0, "rotation": 450.0, "side": "top"}, {"ref": "R2", "pattern": "R_0603", "x": 3.0, "y": 2.0, "rotation": 0.0, "side": "bottom"}, {"ref": "J9", "pattern": "X", "x": 0.0, "y": 0.0}]});+ let r = bom(&json!({"sheetParts": sheet, "board": board, "args": {"boards": 5, "variant": {"name": "lite", "notFitted": ["C1"]}}}));+ // R1, R2, R10 on one line in natural order; TP1 its own line, not purchased; U1 and C1 not fitted; H1 excluded+ assert_eq!(r["lines"][0]["designators"], json!(["R1", "R2", "R10"]), "{r}");+ assert_eq!(r["lines"][0]["quantity"], 3);+ assert_eq!(r["lineCount"], 2);+ assert_eq!(r["checks"]["inconsistent"][0]["field"], "lcsc");+ assert_eq!(r["checks"]["dnp"], json!(["C1", "U1"]));+ assert_eq!(r["checks"]["excludedKinds"], json!(["H1"]));+ assert_eq!(r["checks"]["notPurchased"], json!(["TP1"]));+ assert_eq!(r["checks"]["boardNotOnSheet"], json!(["J9"]));+ assert_eq!(r["checks"]["sheetNotOnBoard"], json!(["C1", "R10", "TP1", "U1"]));+ let file = |n: &str| r["files"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["text"].as_str().unwrap().to_string();+ assert!(file("cpl-jlcpcb.csv").contains("R1,1.0000mm,2.0000mm,Top,90\r\n") && file("cpl-jlcpcb.csv").contains("R2,3.0000mm,2.0000mm,Bottom,0\r\n"), "{}", file("cpl-jlcpcb.csv"));+ assert!(file("bom-mouser.csv").contains(",RC0603FR-0710KL,Yageo,15,\"R1,R2,R10\""), "{}", file("bom-mouser.csv"));+ assert!(!file("bom-jlcpcb.csv").contains("TP1"));+ let x = cross_check(&json!({"sheetParts": [p("R1", "", json!({})), p("R2", "", json!({}))], "board": {"components": [{"ref": "R1"}, {"ref": "J9"}], "pads": []}}));+ assert_eq!((x["matched"].clone(), x["onSheetNotOnBoard"].clone(), x["onBoardNotOnSheet"].clone()), (json!(1), json!(["R2"]), json!(["J9"])), "{x}");+ }+}
engine/src/lib.rs+4−1@@ -10,6 +10,7 @@ use serde_json::Value; pub mod assembly; pub mod autoclass; pub mod bodies;+pub mod bom; pub mod coppermap; pub mod cost; pub mod current;@@ -40,7 +41,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", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "bom", "check_clearance", "check_orientation", "copper_balance", "cross_check", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "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 {@@ -55,9 +56,11 @@ fn answer(check: &str, input: &Value) -> Value { "assembly_check" => assembly::assembly_check(input), "auto_classes" => autoclass::auto_classes(input), "board_report" => report::board_report(input),+ "bom" => bom::bom(input), "check_clearance" => placetools::check_clearance(input), "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input),+ "cross_check" => bom::cross_check(input), "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input), "find_space" => space::find_space(input),
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.29.6",+ "version": "0.30.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.29.6",+ "standard": "0.30.0", "reference": { "bridge": "altium", "version": "0.2.87"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.29.6",+ "standard": "0.30.0", "reference": { "bridge": "altium", "version": "0.2.87"