← Commit history
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"