← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.36\ No newline at end of file+1.13.37\ No newline at end of file
CHANGELOG.md+9
@@ -3,6 +3,15 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.37 (2026-10-30)++- Engine 0.24.0 (standard 0.30.0). `fusion_bom` runs the engine's bom (ported from the Altium bridge, so every bridge+  answers alike): the schematic's parts with their attributes, the live board and the current or named variant, one+  line per MPN, the order checks, and bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv and bom-digikey.csv written+  to outputDir. cross_check stays Fusion's own live sheet-to-board compare.+- `fusion_render_board_3d {refs}`: the body check used the occurrence's component (an Occurrence has no+  allOccurrences, seen live on 1.13.36); a body-less part is listed in framed.missing.+ ## 1.13.36 (2026-10-30)  - `fusion_board_bom` (and fusion_bom, jlcpcb_order, assembly_check, which read it): parts without an MPN were one line per
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.36",+  "version": "1.13.37",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
contracts/describe.json+3−2
@@ -967,13 +967,14 @@       "output": {         "alternates": "[{designator, alternatePart}]",         "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}",-        "files": "[path]",+        "files": "[path] (write:false answers the files' text instead)",         "fittedParts": "int",         "lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]",+        "outputDir": "str",         "variants": "[str]"       },       "statusVerb": null,-      "summary": "The bill of materials in the standard's shape (the standard's bom), from Fusion's own reads: the board's lines grouped by value, footprint and part numbers (fusion_board_bom, which writes bom.csv and bom-jlcpcb.csv), each part's description and library reference from the schematic, and the variant's not-fitted parts left out (variant names one; default the current). checks lists missing MPN, manufacturer and distributor numbers, the same value and footprint under different part numbers, DNP parts, parts on the schematic but not the board and the reverse.",+      "summary": "The bill of materials (the standard's bom) through the shared engine: the schematic's parts with their attributes, the live board and the variant (variant names one; default the current), grouped one line per manufacturer part number, checked for what an order needs (missing MPN, maker, Mouser/DigiKey/LCSC numbers, lines mixing values, JLCPCB lines without LCSC, DNP, sheet and board mismatches), and the order files written to outputDir: bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv.",       "timeoutSeconds": 180     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.36"+version = "1.13.37" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.36"+version = "1.13.37" edition = "2021"  [dependencies]
describe.py+2−2
@@ -237,9 +237,9 @@ _T = [     ("fusion_render_board_layers", "Chosen board layers as an image without a window (the standard's render_board_layers), through Fusion's EXPORT IMAGE: a preset (all, top_copper, bottom_copper, top_silkscreen, bottom_silkscreen, top_soldermask, bottom_soldermask, top_paste, bottom_paste, outline, drill, assembly_top, assembly_bottom, fabrication) or layers by the standard's copper names (F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion's numbers; dpi, mirror, monochrome. png only: Fusion writes no svg, so format svg is refused by name.",      {"preset": "a preset name, default all", "layers": "[layer] overriding preset", "format": "png (svg refused)", "dpi": "int, default 300 (max 600)", "mirror": "bool", "monochrome": "bool", "outputPath": "the png on the Fusion machine", "listPresets": "bool", "expectDocument": "the board's name"},      {"path": "str", "format": "png", "widthPx": "int", "heightPx": "int", "preset": "str or custom"}, 120, None, False, {"preset": "top_copper"}),-    ("fusion_bom", "The bill of materials in the standard's shape (the standard's bom), from Fusion's own reads: the board's lines grouped by value, footprint and part numbers (fusion_board_bom, which writes bom.csv and bom-jlcpcb.csv), each part's description and library reference from the schematic, and the variant's not-fitted parts left out (variant names one; default the current). checks lists missing MPN, manufacturer and distributor numbers, the same value and footprint under different part numbers, DNP parts, parts on the schematic but not the board and the reverse.",+    ("fusion_bom", "The bill of materials (the standard's bom) through the shared engine: the schematic's parts with their attributes, the live board and the variant (variant names one; default the current), grouped one line per manufacturer part number, checked for what an order needs (missing MPN, maker, Mouser/DigiKey/LCSC numbers, lines mixing values, JLCPCB lines without LCSC, DNP, sheet and board mismatches), and the order files written to outputDir: bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv.",      {"expectDocument": "the board's name", "variant": "a variant's name (default the current one)", "boards": "int, default 1: multiplies quantities", "outputDir": "folder for bom.csv and bom-jlcpcb.csv", "write": "bool, default true"},-     {"lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]", "fittedParts": "int", "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}", "variants": "[str]", "alternates": "[{designator, alternatePart}]", "files": "[path]"}, 180, None, False, {}),+     {"lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]", "fittedParts": "int", "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}", "variants": "[str]", "alternates": "[{designator, alternatePart}]", "files": "[path] (write:false answers the files' text instead)", "outputDir": "str"}, 180, None, False, {}),     ("fusion_edit_parts", "Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. Fusion keeps board and schematic in step, so the edit is in the schematic too. dnp, excludeFromBom, excludeFromPos and mount are refused per part (Fusion keeps them in assembly variants).",      {"edits": "[{ref, value?, newRef?, fields?: {name: value | null}, locked?: bool}] (required)", "expectDocument": "required: the board's name", "expectedRevision": "optional", "dryRun": "bool"},      {"edited": "[{ref, after: {ref, value, fields, locked}}]", "failed": "[{ref, error}]", "revision": "str"},
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.36"+DASH_VERSION="1.13.37" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.36",+  "version": "1.13.37",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.36",+  "version": "1.13.37",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+6−6
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.23.0"+version = "0.24.0" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.36"+version = "1.13.37" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.36"+version = "1.13.37" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.36"+version = "1.13.37" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.36"+version = "1.13.37" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.36"+version = "1.13.37" dependencies = [  "fusion-core",  "windows-sys 0.59.0",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.36"+version = "1.13.37" edition = "2021" license = "MIT" 
rust/bridge/src/checks.rs+4−2
@@ -7,7 +7,9 @@ use serde_json::{json, Value};  // footprint_from_land_pattern needs no board: it writes a .lbr offline (main.rs native_lib_authoring) // panelize builds a new board from the plan and opens it: boardgen::panelize, which has the Fusion context-pub fn owns(cmd: &str) -> bool { !matches!(cmd, "footprint_from_land_pattern" | "panelize" | "lint_library") && adom_eda_checks::CHECKS.contains(&cmd) }+// bom is stdshapes::bom (the engine with the schematic's parts, the variant and the files written);+// cross_check stays Fusion's own sheet-to-board compare (sch_script), which reads both documents live+pub fn owns(cmd: &str) -> bool { !matches!(cmd, "footprint_from_land_pattern" | "panelize" | "lint_library" | "bom" | "cross_check") && adom_eda_checks::CHECKS.contains(&cmd) }  /// A pcb-design-rules record for a fab (adom/pcb-design-rules rules/records/<vendor>.json): the /// record for the board's copper layer count, or the asked process.@@ -317,7 +319,7 @@ mod silk_tests {  /// fusion_schematic_data's components as the standard's sheetParts: {designator, pins: [{number, /// name, electrical}]}, a pin that lands on several pads listed once per pad.-fn sheet_parts(sd: &Value) -> Vec<Value> {+pub(crate) fn sheet_parts(sd: &Value) -> Vec<Value> {     // a symbol with no package (EAGLE's supply symbols: GND, VCC) is no part: it has no pads, and it     // joins its net by the pin's name     sd["components"].as_array().cloned().unwrap_or_default().iter()
rust/bridge/src/docs_script.py+1−1
@@ -155,7 +155,7 @@ def render_board_3d(app, args):                 for c in o.childOccurrences:                     r = c.name.rsplit(':', 1)[-1].upper()                     # a part with no 3D body has an empty box at the origin: it cannot be framed-                    has_body = c.bRepBodies.count > 0 or any(x.bRepBodies.count for x in c.allOccurrences)+                    has_body = c.bRepBodies.count > 0 or any(x.bRepBodies.count for x in c.component.allOccurrences)                     if r in want and has_body and c.boundingBox:                         boxes.append(c.boundingBox)                         found.add(r)
rust/bridge/src/stdshapes.rs+32−45
@@ -212,60 +212,47 @@ pub fn render_board_layers(args: &Value, pass: &dyn Fn(&str, Value) -> Value) ->     r } -/// `fusion_bom`: the standard's bom over Fusion's own reads: board_bom (the board's lines and their-/// part numbers, written as bom.csv and bom-jlcpcb.csv), schematic_data (each part's description and-/// device) and variants (not-fitted parts and alternates). `variant` names one; default the current.+/// `fusion_bom`: the standard's bom through the shared engine (adom-eda-checks bom): the schematic's+/// parts with their attributes (sheetParts), the live board, and the variant (`variant` names one;+/// default the current). The engine groups, checks and writes the order files as text; this writes+/// them to outputDir (bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv). pub fn bom(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {-    let mut ba = json!({"expectDocument": args["expectDocument"]});-    if args["write"].as_bool() != Some(false) { if let Some(d) = args["outputDir"].as_str() { ba["outputDir"] = json!(d); } }-    let bb = crate::routing::call("board_bom", &ba, pass);-    if bb["success"].as_bool() == Some(false) { return bb; }-    let mut sa = json!({"expectDocument": args["expectDocument"], "params": true});-    if let Some(l) = args["limit"].as_u64() { sa["limit"] = json!(l); }-    let sd = crate::sch::call("schematic_data", &sa, pass);-    let sheet: std::collections::HashMap<String, Value> = sd["components"].as_array().cloned().unwrap_or_default().into_iter()-        .filter_map(|c| c["designator"].as_str().map(|d| (d.to_string(), c.clone()))).collect();+    let inputs = crate::routing::call("check_inputs", args, pass);+    if inputs["success"].as_bool() != Some(true) { return inputs; }+    let sd = crate::sch::call("schematic_data", &json!({"expectDocument": args["expectDocument"], "params": true, "limit": 5000}), pass);+    if sd["success"].as_bool() == Some(false) { return sd; }     let vr = crate::sch::call("variants", &json!({"expectDocument": args["expectDocument"]}), pass);     let variants = vr["variants"].as_array().cloned().unwrap_or_default();     let chosen = match args["variant"].as_str() {         Some(n) => match variants.iter().find(|v| v["name"].as_str() == Some(n)) { Some(v) => Some(v.clone()), None => return json!({"success": false, "errorCode": "unknown_variant", "error": format!("No variant {n}"), "variants": variants.iter().map(|v| v["name"].clone()).collect::<Vec<_>>()}) },         None => variants.iter().find(|v| v["current"] == true).cloned(),     };-    let dnp: Vec<String> = chosen.as_ref().map(|v| v["notFitted"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();-    let boards = args["boards"].as_u64().unwrap_or(1).max(1);-    let param = |c: &Value, keys: &[&str]| -> Value { for k in keys { if let Some(s) = c["params"][*k].as_str().filter(|s| !s.trim().is_empty()) { return json!(s); } } Value::Null };-    let mut lines = Vec::new();-    let (mut fitted, mut no_mpn, mut no_mfr) = (0u64, Vec::new(), Vec::new());-    for l in bb["lines"].as_array().into_iter().flatten() {-        let des: Vec<String> = l["designators"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())-            .or_else(|| l["designators"].as_str().map(|s| s.split(|c: char| c == ',' || c.is_whitespace()).filter(|x| !x.is_empty()).map(str::to_string).collect())).unwrap_or_default();-        let fit: Vec<String> = des.iter().filter(|d| !dnp.contains(d)).cloned().collect();-        if fit.is_empty() { continue; }-        let first = sheet.get(&fit[0]).cloned().unwrap_or(json!({}));-        fitted += fit.len() as u64;-        if l["mpn"].as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { no_mpn.extend(fit.iter().cloned()); }-        if l["manufacturer"].as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { no_mfr.extend(fit.iter().cloned()); }-        lines.push(json!({"quantity": fit.len() as u64 * boards, "designators": fit, "value": l["value"], "comment": first["comment"].clone(),-            "description": param(&first, &["DESCRIPTION", "DESC", "Description"]), "footprint": l["package"], "package": l["package"],-            "mpn": l["mpn"], "manufacturer": l["manufacturer"], "mouser": l["mouser"], "digikey": l["digikey"], "lcsc": l["lcsc"], "libReference": first["libReference"].clone()}));+    let mut eargs = json!({"boards": args["boards"].as_u64().unwrap_or(1)});+    if let Some(v) = &chosen {+        eargs["variant"] = json!({"name": v["name"], "notFitted": v["notFitted"],+            "alternates": v["alternates"].as_array().cloned().unwrap_or_default().iter().map(|a| json!({"designator": a["designator"], "part": a["value"]})).collect::<Vec<_>>()});     }-    // the same value and footprint ordered under different part numbers-    let mut inconsistent = Vec::new();-    for field in ["mpn", "manufacturer"] {-        let mut by: std::collections::BTreeMap<(String, String), Vec<(String, Vec<Value>)>> = Default::default();-        for l in &lines { let k = (l["value"].as_str().unwrap_or("").to_string(), l["package"].as_str().unwrap_or("").to_string()); by.entry(k).or_default().push((l[field].as_str().unwrap_or("").to_string(), l["designators"].as_array().cloned().unwrap_or_default())); }-        for ((_v, _p), rows) in by { let vals: std::collections::BTreeSet<&String> = rows.iter().map(|r| &r.0).collect(); if vals.len() > 1 {-            inconsistent.push(json!({"field": field, "designators": rows.iter().flat_map(|r| r.1.clone()).collect::<Vec<_>>(), "values": vals.into_iter().collect::<Vec<_>>()})); } }+    let bundle = json!({"sheetParts": crate::checks::sheet_parts(&sd), "board": inputs["board"], "args": eargs});+    let mut r = adom_eda_checks::run("bom", &bundle);+    if r["success"].as_bool() != Some(true) { return r; }+    if args["write"].as_bool() != Some(false) {+        let stem = sd["documentName"].as_str().or(inputs["documentName"].as_str()).unwrap_or("board").chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();+        let dir = args["outputDir"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| format!("{}/adom-bom/{stem}", std::env::temp_dir().to_string_lossy().replace('\\', "/")));+        if let Err(e) = std::fs::create_dir_all(&dir) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }+        let mut paths = Vec::new();+        for f in r["files"].as_array().cloned().unwrap_or_default() {+            let p = format!("{dir}/{}", f["name"].as_str().unwrap_or("bom.csv"));+            if let Err(e) = std::fs::write(&p, f["text"].as_str().unwrap_or("")) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }+            paths.push(json!(p));+        }+        let _ = r["files"].take();+        r["files"] = json!(paths);+        r["outputDir"] = json!(dir);     }-    let board_refs: std::collections::HashSet<String> = bb["lines"].as_array().into_iter().flatten().flat_map(|l| l["designators"].as_array().cloned().unwrap_or_default()).filter_map(|x| x.as_str().map(str::to_string)).collect();-    let sheet_not_on_board: Vec<&String> = sheet.iter().filter(|(d, c)| !board_refs.contains(*d) && c["device"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|(d, _)| d).collect();-    let files: Vec<Value> = bb["files"].as_object().map(|m| m.values().cloned().collect()).unwrap_or_default();-    let alternates: Vec<Value> = chosen.as_ref().map(|v| v["alternates"].as_array().cloned().unwrap_or_default().into_iter().map(|a| json!({"designator": a["designator"], "alternatePart": a["value"]})).collect()).unwrap_or_default();-    json!({"success": true, "mutated": false, "lines": lines, "fittedParts": fitted * boards, "files": files, "alternates": alternates,-           "variants": variants.iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), "variant": chosen.as_ref().map(|v| v["name"].clone()),-           "checks": {"missingMpn": no_mpn, "missingManufacturer": no_mfr, "missingPartNumbers": bb["missingPartNumbers"], "inconsistent": inconsistent,-                      "dnp": dnp, "excludedKinds": [], "sheetNotOnBoard": sheet_not_on_board, "boardNotOnSheet": bb["boardOnly"]},-           "_hint": "Lines are the board's parts grouped by value, footprint and part numbers; DNP parts of the variant are left out. bom.csv and bom-jlcpcb.csv are in files."})+    r["variants"] = json!(variants.iter().map(|v| v["name"].clone()).collect::<Vec<_>>());+    r["mutated"] = json!(false);+    r["shell"] = json!("rust");+    r }  #[cfg(test)]
rust/vendor/adom-eda-checks/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"
rust/vendor/adom-eda-checks/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 |
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.28.0-- Standard commit: dc0d3953dd8e58d149b4bba145aaa3d7d7292886-- Crate version: 0.23.0+- Standard version: 0.30.0+- Standard commit: 25dd7d1e87e961f5fa197a728a1f2b2c3025175a+- Crate version: 0.24.0 - Copied: 2026-10-30, by fusion-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/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}");+    }+}
rust/vendor/adom-eda-checks/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),