← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.24\ No newline at end of file+1.13.25\ No newline at end of file
CHANGELOG.md+11
@@ -3,6 +3,17 @@ 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.25 (2026-10-30)++- Engine 0.21.0 (standard 0.24.0). `fusion_lint_library` (the standard's lint_library): a .lbr read offline (pads,+  courtyard on tKeepout, silk on tPlace, 3D packages, descriptions; each component's pins, footprint and MPN) and judged+  by the shared engine, every problem a finding with a rule name and a severity.+- `fusion_update_footprints_from_library` (the standard's update_footprints_from_library, #27), the plan: each placed+  footprint against the same-named package in the .lbr files named, pad by pad (up_to_date, differs with the diff, or+  library_not_found). apply is refused with the plan: Fusion's UPDATE asks about each part in a dialog.+- `fusion_edit_footprint` writes a library that has footprints but no components yet (it checked it with the full+  preflight, which demands symbols, and refused). 349 verbs.+ ## 1.13.24 (2026-10-30)  - `fusion_list_installed_libraries` (the standard's list_installed_libraries): the libraries Fusion offers to place
bridge.json+5−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.24",+  "version": "1.13.25",   "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.",@@ -182,6 +182,7 @@     "fusion_library_parts",     "fusion_library_views",     "fusion_license_status",+    "fusion_lint_library",     "fusion_list_cloud_files",     "fusion_list_cloud_projects",     "fusion_list_commands",@@ -360,6 +361,7 @@     "fusion_thermal_vias",     "fusion_uninstall",     "fusion_update_board_from_eagle",+    "fusion_update_footprints_from_library",     "fusion_variants",     "fusion_verb_times",     "fusion_via_fence",@@ -527,6 +529,8 @@       "project_snapshot": 300,       "project_create": 180,       "test_board": 180,+      "update_footprints_from_library": 120,+      "lint_library": 30,       "add_symbol_art": 30,       "list_installed_libraries": 300,       "diagnostics": 30,
contracts/describe.json+47
@@ -3452,6 +3452,30 @@       "summary": "The EDA Bridge Standard's name for fusion_account: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",       "timeoutSeconds": 120     },+    {+      "example": {+        "libraryPath": "C:/libs/parts.lbr"+      },+      "input": {+        "libraryPath": ".lbr on the Fusion machine (required)",+        "names": "[footprint or component names] (default all)"+      },+      "longRunning": false,+      "name": "fusion_lint_library",+      "output": {+        "checked": "int",+        "engine": "str",+        "errors": "int",+        "findings": "[{name, kind, rule, severity, detail}]",+        "infos": "int",+        "ok": "bool",+        "unread": "[]",+        "warnings": "int"+      },+      "statusVerb": null,+      "summary": "Is this library fit to ship (the standard's lint_library)? A .lbr read offline and judged by the shared checks engine: each footprint's pads (none, zero-size, unnumbered, duplicate numbers), courtyard (tKeepout), silkscreen, 3D package and description, and each component's pins against its footprint's pads, its linked footprint and its MPN. Every problem is a finding with a rule name and a severity.",+      "timeoutSeconds": 30+    },     {       "example": {         "projectName": "Main"@@ -22787,6 +22811,29 @@       "summary": "Make the open design's board the board in an EAGLE .brd (or a .fbrd) on the Fusion machine, in place, so the design keeps its files and gets a new version instead of a new design (page issue #125, item 4): parts moved and turned by name, tracks and vias ripped up and drawn from the file, pours, the outline, holes and net-less copper replaced, then the whole board read back against the file. With save:true it is saved in place, and only after an exact read back. A file whose parts or nets differ (a schematic change) is refused with schematic_differs. Silkscreen and documentation, design rules and name/value positions are not synced; notSynced says when the file differs in them.",       "timeoutSeconds": 300     },+    {+      "example": {+        "libraryPath": "C:/libs/parts.lbr"+      },+      "input": {+        "apply": "refused: see above",+        "expectDocument": "the board (required)",+        "libraries": "[.lbr] (instead of libraryPath)",+        "libraryPath": ".lbr to compare against",+        "refs": "[ref] (default every part)"+      },+      "longRunning": false,+      "name": "fusion_update_footprints_from_library",+      "output": {+        "differs": "int",+        "footprints": "[{ref, footprint, library, status, diff?: {padsAdded, padsRemoved, padsChanged: [{pad, changes}]}, why?}]",+        "plan": "true",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "Compare placed footprints with their library (the standard's update_footprints_from_library), the plan: each part's footprint as the board holds it against the same-named package in the .lbr files named, pad by pad (added, removed, moved, resized, reshaped, drill, turn, layer), each up_to_date, differs or library_not_found. A Fusion design's libraries are cloud items, so the .lbr files are named. apply:true is refused (not_supported) with the plan: Fusion's UPDATE asks about each part in a dialog.",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.24"+version = "1.13.25" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.24"+version = "1.13.25" edition = "2021"  [dependencies]
describe.py+8
@@ -454,6 +454,14 @@ _T = [      {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)", "kicadSym": ".kicad_sym on the Fusion machine carrying the baked outline (required)", "clear": "bool, default true", "dryRun": "bool"},      {"component": "str", "polylines": "int", "points": "int", "lines": "int", "removed": "int", "bodyRectMil": "[x1, y1, x2, y2]"},      30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "AMPLH5030S-150MT", "kicadSym": "C:/staged/AMPLH5030S-150MT.kicad_sym", "dryRun": True}),+    ('fusion_lint_library', "Is this library fit to ship (the standard's lint_library)? A .lbr read offline and judged by the shared checks engine: each footprint's pads (none, zero-size, unnumbered, duplicate numbers), courtyard (tKeepout), silkscreen, 3D package and description, and each component's pins against its footprint's pads, its linked footprint and its MPN. Every problem is a finding with a rule name and a severity.",+     {"libraryPath": ".lbr on the Fusion machine (required)", "names": "[footprint or component names] (default all)"},+     {"ok": "bool", "checked": "int", "errors": "int", "warnings": "int", "infos": "int", "findings": "[{name, kind, rule, severity, detail}]", "unread": "[]", "engine": "str"},+     30, None, False, {"libraryPath": "C:/libs/parts.lbr"}),+    ('fusion_update_footprints_from_library', "Compare placed footprints with their library (the standard's update_footprints_from_library), the plan: each part's footprint as the board holds it against the same-named package in the .lbr files named, pad by pad (added, removed, moved, resized, reshaped, drill, turn, layer), each up_to_date, differs or library_not_found. A Fusion design's libraries are cloud items, so the .lbr files are named. apply:true is refused (not_supported) with the plan: Fusion's UPDATE asks about each part in a dialog.",+     {"expectDocument": "the board (required)", "libraryPath": ".lbr to compare against", "libraries": "[.lbr] (instead of libraryPath)", "refs": "[ref] (default every part)", "apply": "refused: see above"},+     {"plan": "true", "differs": "int", "footprints": "[{ref, footprint, library, status, diff?: {padsAdded, padsRemoved, padsChanged: [{pad, changes}]}, why?}]", "revision": "str"},+     120, None, False, {"libraryPath": "C:/libs/parts.lbr"}),     ('fusion_test_board', "Footprints from a .lbr on ONE board (the standard's test_board): a NEW board (wMm x hMm outline, or one that fits the row) when pcbPath is new or omitted, APPENDED right of the current parts when pcbPath is an existing .brd, designators continuing (U4...). Written as EAGLE source, opened in Fusion as a local design dialog-free, and read back. assigns and stubs apply to the LAST part placed.",      {"libraryPath": "a .lbr on the Fusion machine (required)", "footprintName": "str | 'A;B;C' (required)", "pcbPath": "a .brd: existing = append, new = create (default %TEMP%/adom-test-boards/<name>.brd)", "wMm": "number (new board only)", "hMm": "number (new board only)", "assigns": "pad=NET;... (last part)", "stubs": "pad=x,y;... mm from the part's origin (last part): a 0.2 mm top track", "prefix": "designator prefix, default U", "layers": "2|4|6|8 (new board)", "open": "bool, default true"},      {"appended": "bool", "pcb": "path", "placed": "'U1;U2'", "count": "int (parts on the board)", "existingParts": "int", "padsOnComp": "int", "bodiesOnComp": "int", "saved": "bool (checked on disk)", "documentName": "str", "unknownPins": "[]"},
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.24"+DASH_VERSION="1.13.25" 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.24",+  "version": "1.13.25",   "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.24",+  "version": "1.13.25",   "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.20.0"+version = "0.21.0" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.24"+version = "1.13.25" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.24"+version = "1.13.25" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.24"+version = "1.13.25" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.24"+version = "1.13.25" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.24"+version = "1.13.25" 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.24"+version = "1.13.25" edition = "2021" license = "MIT" 
rust/bridge/src/boardgen.rs+94
@@ -395,6 +395,88 @@ pub fn panelize(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) ->     } } +/// A package's pads by name: (x, y, w, h, shape, drill, turn, layer).+fn pad_rows(pkg: &Element) -> BTreeMap<String, (f64, f64, f64, f64, String, f64, f64, String)> {+    pkg.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| {+        let n = c.attr("name")?.to_string();+        let row = if c.tag == "smd" { (num(c, "x"), num(c, "y"), num(c, "dx"), num(c, "dy"), format!("smd roundness {}", c.attr("roundness").unwrap_or("0")), 0.0, rot_deg(c), c.attr("layer").unwrap_or("1").to_string()) }+                  else { let d = num(c, "diameter"); (num(c, "x"), num(c, "y"), d, d, format!("pad {}", c.attr("shape").unwrap_or("round")), num(c, "drill"), rot_deg(c), "1-16".into()) };+        Some((n, row))+    }).collect()+}++/// What differs between a placed footprint (the board's embedded package) and the library's.+fn pad_diff(board: &Element, lib: &Element) -> Option<Value> {+    let (a, b) = (pad_rows(board), pad_rows(lib));+    let added: Vec<&String> = b.keys().filter(|k| !a.contains_key(*k)).collect();+    let removed: Vec<&String> = a.keys().filter(|k| !b.contains_key(*k)).collect();+    let mut changed = Vec::new();+    for (k, x) in &a {+        let Some(y) = b.get(k) else { continue };+        let mut c = Vec::new();+        let e = |p: f64, q: f64| (p - q).abs() > 1e-4;+        if e(x.0, y.0) || e(x.1, y.1) { c.push(format!("at ({}, {}) -> ({}, {})", f(x.0), f(x.1), f(y.0), f(y.1))); }+        if e(x.2, y.2) || e(x.3, y.3) { c.push(format!("size {} x {} -> {} x {}", f(x.2), f(x.3), f(y.2), f(y.3))); }+        if x.4 != y.4 { c.push(format!("shape {} -> {}", x.4, y.4)); }+        if e(x.5, y.5) { c.push(format!("drill {} -> {}", f(x.5), f(y.5))); }+        if e(x.6, y.6) { c.push(format!("turn {} -> {}", f(x.6), f(y.6))); }+        if x.7 != y.7 { c.push(format!("layer {} -> {}", x.7, y.7)); }+        if !c.is_empty() { changed.push(json!({"pad": k, "changes": c})); }+    }+    if added.is_empty() && removed.is_empty() && changed.is_empty() { None } else { Some(json!({"padsAdded": added, "padsRemoved": removed, "padsChanged": changed})) }+}++/// `fusion_update_footprints_from_library`: the standard's update_footprints_from_library, the plan.+/// Each placed footprint (the package the board embeds) is compared pad by pad with the same-named+/// package in the libraries given (a Fusion design's libraries are cloud items, so the .lbr files to+/// compare against are named). apply is refused: Fusion's UPDATE asks about each part in a dialog,+/// which the bridge never raises.+pub fn update_footprints_from_library(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let paths: Vec<String> = match (args["libraries"].as_array(), args["libraryPath"].as_str()) {+        (Some(a), _) => a.iter().filter_map(|x| x.as_str().map(|s| s.replace('\\', "/"))).collect(),+        (None, Some(p)) => vec![p.replace('\\', "/")],+        _ => vec![],+    };+    if paths.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "libraryPath or libraries ([.lbr]) is required: a Fusion design's libraries are cloud items, so name the .lbr files to compare against",+        "_hint": "fusion_export_library_part or the .lbr a part came from; fusion_list_installed_libraries lists the hub's libraries."}); }+    let mut libs: Vec<(String, Vec<Element>)> = Vec::new();+    for p in &paths { match std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|t| lbr_packages(&t)) { Ok(pk) => libs.push((p.clone(), pk)), Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": format!("{p}: {e}")}) } }+    let src = crate::routing::call("board_source", &json!({"expectDocument": args["expectDocument"]}), pass);+    let Some(text) = src["source"].as_str().or(src["data"]["source"].as_str()) else { return json!({"success": false, "errorCode": "board_unreadable", "error": src["error"]}) };+    let root = match Element::parse(text) { Ok(r) => r, Err(e) => return json!({"success": false, "errorCode": "board_unreadable", "error": e.to_string()}) };+    let Some(board) = deep(&root, "board") else { return json!({"success": false, "errorCode": "board_unreadable", "error": "no <board>"}) };+    let refs: Option<Vec<String>> = args["refs"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_uppercase)).collect());+    let blibs = board.find_child("libraries");+    let mut rows = Vec::new();+    let mut differs = 0;+    for e in board.find_child("elements").map(|x| x.children.iter().filter(|c| c.tag == "element").collect::<Vec<_>>()).unwrap_or_default() {+        let (r, l, pk) = (e.attr("name").unwrap_or(""), e.attr("library").unwrap_or(""), e.attr("package").unwrap_or(""));+        if let Some(want) = &refs { if !want.contains(&r.to_uppercase()) { continue; } }+        let placed = blibs.and_then(|b| child_named(b, "library", l)).and_then(|x| x.find_child("packages")).and_then(|x| child_named(x, "package", pk));+        let found = libs.iter().find_map(|(p, pks)| pks.iter().find(|k| k.attr("name").map(|n| n.eq_ignore_ascii_case(pk)).unwrap_or(false)).map(|k| (p.clone(), k)));+        let row = match (placed, found) {+            (Some(pl), Some((path, lk))) => match pad_diff(pl, lk) {+                Some(d) => { differs += 1; json!({"ref": r, "footprint": pk, "library": path, "status": "differs", "diff": d}) }+                None => json!({"ref": r, "footprint": pk, "library": path, "status": "up_to_date"}),+            },+            (_, None) => json!({"ref": r, "footprint": pk, "library": l, "status": "library_not_found", "why": "no package of that name in the libraries given"}),+            (None, Some((path, _))) => json!({"ref": r, "footprint": pk, "library": path, "status": "library_not_found", "why": "the board holds no copy of its package"}),+        };+        rows.push(row);+    }+    let plan = json!({"plan": true, "mutated": false, "revision": src["revision"], "differs": differs, "footprints": rows});+    if args["apply"].as_bool() == Some(true) {+        let mut out = json!({"success": false, "errorCode": "not_supported", "error": "apply is not supported in Fusion: its UPDATE asks about each part in a dialog, which the bridge never raises. The plan is below.",+            "_hint": "Update the parts in Fusion (Library Manager > update, or right-click the part > Update), or swap one footprint on a board kept as EAGLE source with fusion_swap_footprint."});+        for (k, v) in plan.as_object().unwrap() { out[k] = v.clone(); }+        return out;+    }+    let mut out = json!({"success": true});+    for (k, v) in plan.as_object().unwrap() { out[k] = v.clone(); }+    out["_hint"] = json!("A plan: each placed footprint against the same-named package in the libraries given, pad by pad. Fusion updates parts from their library itself (it asks about each one), so apply is refused.");+    out+}+ /// "A;B;C" or a list. fn list(v: &Value, sep: char) -> Vec<String> {     match v { Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect(),@@ -673,6 +755,18 @@ mod tests {         assert!(!xml.contains("boardshape"), "Fusion recomputes the panel's shape");     } +    #[test]+    fn a_widened_pad_reads_as_a_difference() {+        let pk = lbr_packages(LBR).unwrap();+        let mut wide = pk[0].clone();+        for c in wide.children.iter_mut().filter(|c| c.attr("name") == Some("1")) { for a in c.attrs.iter_mut() { if a.0 == "dx" { a.1 = "2".into(); } } }+        let d = pad_diff(&pk[0], &wide).unwrap();+        assert_eq!(d["padsChanged"][0]["pad"], "1");+        assert!(d["padsChanged"][0]["changes"][0].as_str().unwrap().starts_with("size 0.9 x 0.9 -> 2 x 0.9"), "{d}");+        assert!(pad_diff(&pk[0], &pk[0]).is_none());+        assert_eq!(pad_diff(&pk[0], &pk[1]).unwrap()["padsAdded"], json!(["3"]));+    }+     #[test]     fn outlines_refuse_bad_shapes() {         assert!(outline_wires(&json!({"rect": [0, 0, 0, 5]})).is_err());
rust/bridge/src/checks.rs+1−1
@@ -7,7 +7,7 @@ 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") && adom_eda_checks::CHECKS.contains(&cmd) }+pub fn owns(cmd: &str) -> bool { !matches!(cmd, "footprint_from_land_pattern" | "panelize" | "lint_library") && 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.
rust/bridge/src/main.rs+35−5
@@ -508,13 +508,13 @@ fn main() {                     "fusion_generate_body_engine" => { let mut r = body::generate(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },-                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || matches!(v.strip_prefix("fusion_").unwrap_or(v), "replicate_layout" | "silkscreen_prep" | "list_installed_libraries") || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {+                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || matches!(v.strip_prefix("fusion_").unwrap_or(v), "replicate_layout" | "silkscreen_prep" | "list_installed_libraries" | "update_footprints_from_library") || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {                         let (sc, ca) = (sidecar.clone(), caller.clone());                         let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t);                         let dlg = |r: Value| r;                         let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg };                         let cmd=v.strip_prefix("fusion_").unwrap_or(v);-                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "silkscreen_prep" => checks::silkscreen_prep(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "list_installed_libraries" => appverbs::list_installed_libraries(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "silkscreen_prep" => checks::silkscreen_prep(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "list_installed_libraries" => appverbs::list_installed_libraries(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "update_footprints_from_library" => boardgen::update_footprints_from_library(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };                         if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); }                         r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r                     },@@ -691,7 +691,7 @@ fn main() {                     "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]),                     v @ ("fusion_create_footprint" | "create_footprint" | "fusion_create_symbol" | "create_symbol" | "fusion_read_symbol" | "read_symbol" | "fusion_find_symbol" | "find_symbol"                         | "fusion_copy_footprint" | "copy_footprint" | "fusion_rename_footprint" | "rename_footprint" | "fusion_remove_footprint" | "remove_footprint" | "fusion_xcopy_footprint" | "xcopy_footprint"-                        | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility" | "fusion_add_symbol_art" | "add_symbol_art") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),+                        | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility" | "fusion_add_symbol_art" | "add_symbol_art" | "fusion_lint_library" | "lint_library") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),                     "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]),                     "fusion_not_supported" | "not_supported" => unsupported::answer(&parsed["args"]),                     // Native since Phase 2: the raw authenticated APS GET (never-charge guarded).@@ -984,9 +984,11 @@ fn native_edit_footprint(args: &Value) -> Value {     let after = fusion_lbr::read_footprint(&text, None, name).unwrap_or(Value::Null);     let tmp = std::env::temp_dir().join(format!("adom-edit-fp-{}.lbr", std::process::id()));     let _ = std::fs::write(&tmp, &text);-    let pf = fusion_lbr::preflight_lbr(&tmp);+    // the authoring preflight, as create_footprint uses: a footprint-only library (no symbols or+    // components yet) is fine to write; opening a component in it is what needs them+    let (ok, errors) = fusion_lbr::authoring::preflight_text(&tmp);     let _ = std::fs::remove_file(&tmp);-    if !pf.ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": "the edited library would not open cleanly in Fusion; nothing was written", "preflight": pf, "summary": summary, "shell": "rust"}); }+    if !ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": "the edited library would not open cleanly in Fusion; nothing was written", "preflight": errors, "summary": summary, "shell": "rust"}); }     if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "summary": summary, "footprint": after, "shell": "rust"}); }     let bak = format!("{path}.bak");     if let Err(e) = std::fs::copy(&path, &bak).and_then(|_| std::fs::write(&path, &text)) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string(), "shell": "rust"}); }@@ -1032,6 +1034,13 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value {     let existing = std::fs::read_to_string(&path).ok();     let creates = matches!(verb, "create_footprint" | "create_symbol" | "xcopy_footprint" | "footprint_from_land_pattern");     if existing.is_none() && !creates { return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {path}"), "libraryPath": path, "shell": "rust"}); }+    if verb == "lint_library" {+        // the library read into the standard's shape, judged by the shared engine+        return match a::lint_input(existing.as_deref().unwrap()) {+            Ok(lib) => { let mut r = lint_judge(lib, args); r["libraryPath"] = json!(path); r["shell"] = json!("rust"); r }+            Err(e) => json!({"success": false, "errorCode": "library_unreadable", "error": e, "shell": "rust"}),+        };+    }     if verb == "symbol_prims" {         let name = s("symbolName").or(s("name")).unwrap_or_default();         return match a::symbol_prims(existing.as_deref().unwrap(), &name) { Ok(mut v) => { v["success"] = json!(true); v["mutated"] = json!(false); v["libraryPath"] = json!(path); v["shell"] = json!("rust"); v }, Err(e) => json!({"success": false, "errorCode": "symbol_not_found", "error": e, "shell": "rust"}) };@@ -1101,6 +1110,11 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value {            "_hint": "Written and preflighted. A Fusion library is opened with fusion_open_lbr (or fusion_show_footprint / fusion_show_symbol to see one item); a library already open in Fusion does not see the change until it is reopened."}) } +/// The shared engine's lint_library on a library read by fusion_lbr, with names passed through.+fn lint_judge(lib: Value, args: &Value) -> Value {+    adom_eda_checks::run("lint_library", &json!({"library": lib, "args": {"names": args["names"]}}))+}+ /// `fusion_preflight_lbr`: will this .lbr open cleanly in Fusion? Offline, instant. fn native_preflight_lbr(args: &Value) -> Value {     let path = lbr_path_arg(args, &["libraryPath", "filePath"]);@@ -1772,6 +1786,22 @@ fn chains_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "chainsOwner"  fn addin_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "addinOwner") } +#[cfg(test)]+mod lint_library_tests {+    use super::*;+    #[test]+    fn a_library_read_by_fusion_lbr_is_judged_by_the_engine() {+        let sym = "META|LDO-X|reg|U\nMODE|0|n\nRECT|0|0|-300|600|0\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|2|OUT|600|-100|right|300||power_out\nPIN|0|3|EN|0|-200|left|300||input\n";+        let fp = "META|SOT-X|x\nPAD|1|-1|0|1|0.6|rect|0|top|0\nPAD|2|1|0|1|0.6|rect|0|top|0\nPAD|3|1|1|1|0.6|rect|0|top|0\nLINE|courtyard|-2|-1|2|1|0.05\n";+        let (xml, _) = fusion_lbr::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();+        let r = lint_judge(fusion_lbr::authoring::lint_input(&xml).unwrap(), &json!({}));+        let rules: Vec<&str> = r["findings"].as_array().unwrap().iter().filter_map(|f| f["rule"].as_str()).collect();+        assert_eq!(r["errors"], 0, "{r}");+        assert!(rules.contains(&"no_silkscreen") && rules.contains(&"no_3d_body") && rules.contains(&"no_part_number"), "{rules:?}");+        assert!(!rules.contains(&"no_courtyard") && !rules.contains(&"pin_without_pad"), "{rules:?}");+    }+}+ #[cfg(test)] mod standard_alias_tests {     #[test]
rust/lbr/src/authoring.rs+117
@@ -651,6 +651,98 @@ pub fn add_symbol_art(xml: &str, name: &str, kicad_sym: &str, clear: bool) -> Re     Ok((root.to_xml_document(), json!({"component": comp, "polylines": polys.len(), "points": polys.iter().map(|p| p.len()).sum::<usize>(), "lines": drawn, "removed": removed, "bodyRectMil": body_out}))) } +/// The standard's lint_library input, read from an EAGLE library: each package as a footprint+/// (pads in its own frame, mm; holes as mechanical pads; the courtyard from tKeepout, silk from+/// tPlace, its 3D packages, its description) and each component as a symbol per device (pin numbers+/// from the device's connects, its package, the MPN attribute). The engine judges them.+pub fn lint_input(xml: &str) -> Result<Value, String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+    let turned = |e: &Element| e.attr("rot").map(|r| { let d: f64 = r.trim_start_matches(['M', 'S', 'R']).parse().unwrap_or(0.0); ((d / 90.0).round() as i64).rem_euclid(2) == 1 }).unwrap_or(false);+    // which packages a 3D package instances+    let mut bodies: std::collections::BTreeMap<String, Vec<Value>> = Default::default();+    if let Some(p3) = lib.find_child("packages3d") {+        for d in p3.children.iter().filter(|c| c.tag == "package3d") {+            for pi in d.find_child("packageinstances").map(|x| x.children.clone()).unwrap_or_default() {+                if let Some(n) = pi.attr("name") { bodies.entry(n.to_string()).or_default().push(json!({"name": d.attr("name"), "urn": d.attr("urn").or(d.attr("wip_urn"))})); }+            }+        }+    }+    let mut fps = Vec::new();+    for pk in section(lib, "packages").children.iter().filter(|c| c.tag == "package") {+        let name = pk.attr("name").unwrap_or("").to_string();+        let mut pads = Vec::new();+        let (mut court, mut silk) = (Vec::new(), 0usize);+        for c in &pk.children {+            match c.tag.as_str() {+                "smd" => { let (w, h) = (num(c, "dx"), num(c, "dy")); let (w, h) = if turned(c) { (h, w) } else { (w, h) };+                           pads.push(json!({"number": c.attr("name").unwrap_or(""), "x": num(c, "x"), "y": num(c, "y"), "w": w, "h": h})); }+                "pad" => { let d = if c.attr("diameter").is_some() { num(c, "diameter") } else { num(c, "drill") * 1.6 };+                           pads.push(json!({"number": c.attr("name").unwrap_or(""), "x": num(c, "x"), "y": num(c, "y"), "w": d, "h": d, "hole": num(c, "drill")})); }+                "hole" => pads.push(json!({"number": "", "x": num(c, "x"), "y": num(c, "y"), "w": num(c, "drill"), "h": num(c, "drill"), "hole": num(c, "drill"), "mechanical": true, "plated": false})),+                _ => {}+            }+            match c.attr("layer") {+                Some("39") => {+                    for (kx, ky) in [("x1", "y1"), ("x2", "y2"), ("x", "y")] { if c.attr(kx).is_some() { court.push(json!([num(c, kx), num(c, ky)])); } }+                    for v in c.children.iter().filter(|v| v.tag == "vertex") { court.push(json!([num(v, "x"), num(v, "y")])); }+                }+                Some("21") => silk += 1,+                _ => {}+            }+        }+        fps.push(json!({"name": name, "pads": pads, "courtyard": court, "silk": if silk > 0 { json!([silk]) } else { json!([]) },+                        "bodies": bodies.get(&name).cloned().unwrap_or_default(), "description": pk.find_child("description").map(|d| d.text.trim().to_string()).unwrap_or_default()}));+    }+    let mut syms = Vec::new();+    let _ = section(lib, "symbols");+    let sets: Vec<Element> = section(lib, "devicesets").children.iter().filter(|c| c.tag == "deviceset").cloned().collect();+    let lib = &*lib;+    for ds in &sets {+        let dsn = ds.attr("name").unwrap_or("").to_string();+        let devs: Vec<&Element> = ds.find_child("devices").map(|d| d.children.iter().filter(|x| x.tag == "device").collect()).unwrap_or_default();+        let many = devs.len() > 1;+        for dev in devs {+            let pkg = dev.attr("package").unwrap_or("").to_string();+            let mut pins: Vec<Value> = Vec::new();+            for c in dev.find_child("connects").map(|x| x.children.iter().filter(|c| c.tag == "connect").collect::<Vec<_>>()).unwrap_or_default() {+                for pad in c.attr("pad").unwrap_or("").split_whitespace() { pins.push(json!({"number": pad.split('@').next().unwrap_or(pad), "name": c.attr("pin")})); }+            }+            if pins.is_empty() {+                // a device with no footprint has no connects: its pins carry their numbers in+                // ADOM_PINMAP ("1:G$1.IN;..."), else they are the gates' symbol pins, unnumbered+                let mut map = String::new();+                let mut w = vec![dev];+                while let Some(e) = w.pop() { for c in &e.children { if c.tag == "attribute" && c.attr("name") == Some("ADOM_PINMAP") { map = c.attr("value").unwrap_or("").to_string(); } w.push(c); } }+                if !map.is_empty() {+                    for item in map.split(';').filter(|x| !x.is_empty()) { if let Some((n, gp)) = item.split_once(':') { pins.push(json!({"number": n, "name": gp.split_once('.').map(|x| x.1).unwrap_or(gp)})); } }+                } else {+                    let gates: Vec<String> = ds.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect()).unwrap_or_default();+                    for g in gates {+                        if let Some(sym) = lib.find_child("symbols").and_then(|sec| sec.children.iter().find(|x| x.attr("name") == Some(g.as_str()))) {+                            for p in sym.children.iter().filter(|c| c.tag == "pin") { pins.push(json!({"number": "", "name": p.attr("name")})); }+                        }+                    }+                }+            }+            let mut mpn = String::new();+            let mut walk = vec![dev];+            while let Some(e) = walk.pop() {+                for c in &e.children {+                    if c.tag == "attribute" && ["MPN", "MANUFACTURER_PART_NUMBER", "MANUFACTURER PART NUMBER", "PARTNUMBER", "PART_NUMBER"].contains(&c.attr("name").unwrap_or("").to_uppercase().as_str()) {+                        if let Some(v) = c.attr("value").filter(|v| !v.trim().is_empty()) { mpn = v.to_string(); }+                    }+                    walk.push(c);+                }+            }+            let name = if many { format!("{dsn}{}", dev.attr("name").unwrap_or("")) } else { dsn.clone() };+            syms.push(json!({"name": name, "pins": pins, "footprint": pkg, "partNumber": mpn}));+        }+    }+    Ok(json!({"footprints": fps, "symbols": syms}))+}+ /// The standard's add_models: each footprint becomes another device of the component (EAGLE's /// several-packages-per-component), pins joined by pad number; a footprint already attached is /// skipped, since a device names it once. `sources` holds other .lbr texts a model's footprint is@@ -758,6 +850,31 @@ pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) {     (errs.is_empty(), errs) } +#[cfg(test)]+mod lint_tests {+    use super::*;++    #[test]+    fn a_library_is_read_for_the_engine_and_judged() {+        let sym = "META|LDO-X|reg|U\nMODE|0|n\nRECT|0|0|-300|600|0\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|2|OUT|600|-100|right|300||power_out\n";+        let fp = "META|SOT-X|x\nPAD|1|-1|0|1|0.6|rect|0|top|0\nPAD|2|1|0|1|0.6|rect|0|top|0\nLINE|courtyard|-2|-1|2|1|0.05\n";+        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({"params": {"MPN": "LDO-X-1"}})).unwrap();+        // a footprint-less component keeps its pin numbers in ADOM_PINMAP+        let (bare, _) = create_symbol(None, &json!({"name": "BARE", "pins": "1,A,left,100;2,B,right,100"})).unwrap();+        let b = lint_input(&bare).unwrap();+        assert_eq!(b["symbols"][0]["pins"].as_array().unwrap().len(), 2, "{b}");+        assert_eq!((b["symbols"][0]["pins"][0]["number"].as_str(), b["symbols"][0]["footprint"].as_str()), (Some("1"), Some("")));+        let lib = lint_input(&xml).unwrap();+        assert_eq!(lib["footprints"][0]["pads"].as_array().unwrap().len(), 2);+        assert_eq!(lib["symbols"][0]["partNumber"], "LDO-X-1");+        assert_eq!(lib["symbols"][0]["footprint"], "SOT-X");+        assert_eq!(lib["footprints"][0]["silk"], json!([]), "no silk drawn: sent empty, so the engine reports it");+        assert_eq!(lib["footprints"][0]["bodies"], json!([]));+        assert_eq!(lib["footprints"][0]["courtyard"].as_array().unwrap().len(), 2);+        assert_eq!(lib["symbols"][0]["pins"].as_array().unwrap().len(), 2);+    }+}+ #[cfg(test)] mod art_tests {     use super::*;
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.20.0"+version = "0.21.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+1
@@ -29,6 +29,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs |+| `lint_library` | library | is a symbol or footprint library fit to ship (standard #28): the shared rules with a fixed severity each (`no_pads`, `zero_size_pad`, `duplicate_pad_number`, `no_courtyard`, `origin_off_part`, `pin_without_pad`, `pad_without_pin`, `no_part_number`, ...; `rules` in the reply lists them), checked from the footprints and symbols the bridge sends; a key left out is not checked, a key sent empty is a finding; the bridge's own findings (`tool_refuses_file`) pass through | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |
rust/vendor/adom-eda-checks/VENDOR.md+4−4
@@ -3,10 +3,10 @@ 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.23.5-- Standard commit: 4066e3bad27be7d1c48abd02cb275d4b05c93847-- Crate version: 0.20.0-- Copied: 2026-10-29, by fusion-lane+- Standard version: 0.24.0+- Standard commit: aaeef001d59d48d93e08f58edc0cc705cd5e8c80+- Crate version: 0.21.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`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests.
rust/vendor/adom-eda-checks/src/lib.rs+4−2
@@ -4,7 +4,7 @@ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, library?, args?}` (see `input`). use serde_json::Value;  pub mod assembly;@@ -21,6 +21,7 @@ pub mod input; pub mod landpattern; pub mod lengths; pub mod lengthtune;+pub mod liblint; pub mod pairroute; pub mod panel; pub mod report;@@ -39,7 +40,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", "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", "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"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -65,6 +66,7 @@ fn answer(check: &str, input: &Value) -> Value {         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "length_tune" => lengthtune::length_tune(input),+        "lint_library" => liblint::lint_library(input),         "net_lengths" => lengths::net_lengths(input),         "panelize" => panel::panelize(input),         "pin_pad_check" => schreview::pin_pad_check(input),
rust/vendor/adom-eda-checks/src/liblint.rsadded+207
@@ -0,0 +1,207 @@+//! Library lint (EDA Bridge Standard #28): is a symbol or footprint library fit to ship? The content rules every bridge+//! can share, from the footprints and symbols it already reads; a bridge adds its tool's own (KiCad's+//! `tool_refuses_file`) to the same findings. Severity belongs to the rule, not the bridge. A rule is only evaluated+//! when the bridge sent what it needs: a key left out means "not read" and emits nothing, a key sent empty is a finding.+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};++/// The shared vocabulary: (kind, rule, severity).+pub const RULES: &[(&str, &str, &str)] = &[+    ("footprint", "no_pads", "error"),+    ("footprint", "zero_size_pad", "error"),+    ("footprint", "unnumbered_pad", "warning"),+    ("footprint", "duplicate_pad_number", "warning"),+    ("footprint", "no_courtyard", "warning"),+    ("footprint", "origin_off_part", "warning"),+    ("footprint", "no_silkscreen", "info"),+    ("footprint", "no_3d_body", "info"),+    ("footprint", "no_description", "info"),+    ("symbol", "no_pins", "error"),+    ("symbol", "footprint_not_found", "error"),+    ("symbol", "pin_without_pad", "error"),+    ("symbol", "unnumbered_pin", "warning"),+    ("symbol", "duplicate_pin_number", "warning"),+    ("symbol", "no_footprint", "warning"),+    ("symbol", "pad_without_pin", "warning"),+    ("symbol", "no_part_number", "info"),+    ("library", "tool_refuses_file", "error"),+];++/// How far past the pads and courtyard the origin may sit before it counts as off the part (mm).+pub const ORIGIN_MARGIN_MM: f64 = 1.0;++fn severity(rule: &str) -> &'static str { RULES.iter().find(|r| r.1 == rule).map(|r| r.2).unwrap_or("warning") }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn text(v: &Value) -> String { match v { Value::String(s) => s.trim().to_string(), Value::Number(n) => n.to_string(), _ => String::new() } }+/// Sent but empty: null, "", [] or {}.+fn empty(v: &Value) -> bool { match v { Value::Null => true, Value::String(s) => s.trim().is_empty(), Value::Array(a) => a.is_empty(), Value::Object(o) => o.is_empty(), _ => false } }+/// A pad no pin connects to: a mechanical hole, or unplated.+fn mechanical(p: &Value) -> bool { p["mechanical"] == true || p["plated"] == false }+/// "Lib:Name" and "Name" name the same footprint.+fn base(name: &str) -> &str { name.rsplit(':').next().unwrap_or(name) }+/// "pin 3 has" or "pins 2, 3 have".+fn list(word: &str, items: &[&String]) -> String {+    let names = items.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");+    if items.len() == 1 { format!("{word} {names} has") } else { format!("{word}s {names} have") }+}++/// A courtyard's extent: [x0, y0, x1, y1], {x0, y0, x1, y1}, or a list of [x, y] points.+fn extent(c: &Value) -> Option<(f64, f64, f64, f64)> {+    if let Some(a) = c.as_array() {+        if a.len() == 4 && a.iter().all(|v| v.is_number()) { return Some((f(&a[0]).min(f(&a[2])), f(&a[1]).min(f(&a[3])), f(&a[0]).max(f(&a[2])), f(&a[1]).max(f(&a[3])))); }+        let pts: Vec<(f64, f64)> = a.iter().filter_map(|p| p.as_array().filter(|q| q.len() >= 2).map(|q| (f(&q[0]), f(&q[1])))).collect();+        if pts.is_empty() { return None; }+        return Some(pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))));+    }+    if c["x0"].is_number() { return Some((f(&c["x0"]).min(f(&c["x1"])), f(&c["y0"]).min(f(&c["y1"])), f(&c["x0"]).max(f(&c["x1"])), f(&c["y0"]).max(f(&c["y1"])))); }+    None+}++fn finding(name: &str, kind: &str, rule: &str, detail: String) -> Value {+    json!({"name": name, "kind": kind, "rule": rule, "severity": severity(rule), "detail": detail})+}++/// The findings for one footprint (pads in its own frame, mm, origin at 0,0).+pub fn lint_footprint(fp: &Value) -> Vec<Value> {+    let name = text(&fp["name"]);+    let mut out = Vec::new();+    let pads = fp["pads"].as_array().cloned().unwrap_or_default();+    if pads.is_empty() { out.push(finding(&name, "footprint", "no_pads", "the footprint has no pads".into())); }+    let mut seen: BTreeMap<String, usize> = BTreeMap::new();+    for p in &pads {+        let (w, h, hole) = (f(&p["w"]), f(&p["h"]), f(&p["hole"]));+        if (w <= 0.0 || h <= 0.0) && hole <= 0.0 { out.push(finding(&name, "footprint", "zero_size_pad", format!("pad {} is {w} x {h} mm", text(&p["number"])))); }+        let n = text(&p["number"]);+        if n.is_empty() { if !mechanical(p) { out.push(finding(&name, "footprint", "unnumbered_pad", format!("a pad at ({}, {}) has no number", f(&p["x"]), f(&p["y"])))); } }+        else { *seen.entry(n).or_default() += 1; }+    }+    for (n, k) in &seen { if *k > 1 { out.push(finding(&name, "footprint", "duplicate_pad_number", format!("{k} pads are numbered {n} (fine for a split thermal pad, a mistake otherwise)"))); } }+    let court = fp.get("courtyard");+    if court.map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_courtyard", "no courtyard".into())); }+    // the origin should sit on the part: inside the pads and courtyard, grown by the margin+    let mut b: Option<(f64, f64, f64, f64)> = None;+    let mut grow = |e: (f64, f64, f64, f64)| b = Some(b.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));+    for p in &pads { let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["w"]).max(f(&p["hole"])) / 2.0, f(&p["h"]).max(f(&p["hole"])) / 2.0); grow((x - w, y - h, x + w, y + h)); }+    if let Some(e) = court.and_then(extent) { grow(e); }+    if let Some((x0, y0, x1, y1)) = b {+        let m = ORIGIN_MARGIN_MM;+        if 0.0 < x0 - m || 0.0 > x1 + m || 0.0 < y0 - m || 0.0 > y1 + m {+            let (dx, dy) = ((x0 - m).max(0.0) + (0.0f64).max(-(x1 + m)), (y0 - m).max(0.0) + (0.0f64).max(-(y1 + m)));+            out.push(finding(&name, "footprint", "origin_off_part", format!("the origin is {:.3} mm outside the pads and courtyard (part spans x {x0:.3}..{x1:.3}, y {y0:.3}..{y1:.3})", (dx * dx + dy * dy).sqrt())));+        }+    }+    if fp.get("silk").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_silkscreen", "no silkscreen".into())); }+    if fp.get("bodies").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_3d_body", "no 3D body".into())); }+    if fp.get("description").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_description", "no description".into())); }+    out+}++/// The findings for one symbol, against the footprints the bridge sent (None: footprints not read, so the+/// footprint rules that need them are not evaluated).+pub fn lint_symbol(sym: &Value, footprints: Option<&BTreeMap<String, Value>>) -> Vec<Value> {+    let name = text(&sym["name"]);+    let mut out = Vec::new();+    let pins = sym["pins"].as_array().cloned().unwrap_or_default();+    if pins.is_empty() { out.push(finding(&name, "symbol", "no_pins", "the symbol has no pins".into())); }+    let mut numbers: BTreeMap<String, usize> = BTreeMap::new();+    for p in &pins {+        let n = text(&p["number"]);+        if n.is_empty() { out.push(finding(&name, "symbol", "unnumbered_pin", format!("pin {} has no number", text(&p["name"])))); }+        else { *numbers.entry(n).or_default() += 1; }+    }+    for (n, k) in &numbers { if *k > 1 { out.push(finding(&name, "symbol", "duplicate_pin_number", format!("{k} pins are numbered {n}"))); } }+    let fps: Vec<String> = match sym.get("footprint") {+        Some(Value::Array(a)) => a.iter().map(text).filter(|s| !s.is_empty()).collect(),+        Some(v) => Some(text(v)).filter(|s| !s.is_empty()).into_iter().collect(),+        None => Vec::new(),+    };+    if sym.get("footprint").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_footprint", "no footprint is linked".into())); }+    if let Some(lib) = footprints {+        for fpn in &fps {+            let Some(fp) = lib.get(fpn).or_else(|| lib.get(base(fpn))) else {+                out.push(finding(&name, "symbol", "footprint_not_found", format!("footprint {fpn} is not in the footprint libraries checked")));+                continue;+            };+            let pads: BTreeSet<String> = fp["pads"].as_array().map(|a| a.iter().filter(|p| !mechanical(p)).map(|p| text(&p["number"])).filter(|n| !n.is_empty()).collect()).unwrap_or_default();+            let missing: Vec<&String> = numbers.keys().filter(|n| !pads.contains(*n)).collect();+            if !missing.is_empty() { out.push(finding(&name, "symbol", "pin_without_pad", format!("{} no pad on {fpn}", list("pin", &missing)))); }+            let spare: Vec<&String> = pads.iter().filter(|n| !numbers.contains_key(*n)).collect();+            if !spare.is_empty() { out.push(finding(&name, "symbol", "pad_without_pin", format!("{} no pin on {fpn}", list("pad", &spare)))); }+        }+    }+    if sym.get("partNumber").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_part_number", "no manufacturer part number".into())); }+    out+}++/// lint_library: input {library: {footprints?: [{name, pads: [{number, x, y, w, h, hole?, mechanical?, plated?}],+/// courtyard?, silk?, bodies?, description?}], symbols?: [{name, pins: [{number, name?}], footprint?, partNumber?}],+/// unread?: [{name, error}], findings?: [the bridge's own]}, args: {names?}}. Symbols are checked against the footprints+/// sent (the library's own, or those of footprintLibraries the bridge resolved).+pub fn lint_library(input: &Value) -> Value {+    let lib = &input["library"];+    if !lib.is_object() { return crate::input::refuse("bad_args", "library is required: {footprints?, symbols?, unread?, findings?}"); }+    let names: Option<BTreeSet<String>> = input["args"]["names"].as_array().map(|a| a.iter().map(text).collect());+    let wanted = |v: &Value| names.as_ref().map(|n| n.contains(&text(&v["name"]))).unwrap_or(true);+    let fps = lib["footprints"].as_array().cloned().unwrap_or_default();+    let syms = lib["symbols"].as_array().cloned().unwrap_or_default();+    let by_name: Option<BTreeMap<String, Value>> = lib.get("footprints").filter(|v| v.is_array()).map(|_| fps.iter().map(|fp| (text(&fp["name"]), fp.clone())).flat_map(|(n, fp)| [(base(&n).to_string(), fp.clone()), (n, fp)]).collect());+    let mut findings: Vec<Value> = lib["findings"].as_array().cloned().unwrap_or_default();+    let mut checked = 0;+    for fp in fps.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_footprint(fp)); }+    for s in syms.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_symbol(s, by_name.as_ref())); }+    let count = |sev: &str| findings.iter().filter(|x| x["severity"] == sev).count();+    let (errors, warnings, infos) = (count("error"), count("warning"), count("info"));+    let unread = lib["unread"].as_array().cloned().unwrap_or_default();+    json!({"success": true, "ok": errors == 0 && unread.is_empty(), "checked": checked, "errors": errors, "warnings": warnings, "infos": infos,+        "findings": findings, "unread": unread,+        "rules": RULES.iter().map(|r| json!({"kind": r.0, "rule": r.1, "severity": r.2})).collect::<Vec<_>>(),+        "_hint": "Errors stop a part working; warnings are usually mistakes; infos are gaps. A rule is only checked when the bridge read what it needs, so a missing finding is not a pass for a rule the bridge could not read."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn pad(n: &str, x: f64, y: f64) -> Value { json!({"number": n, "x": x, "y": y, "w": 0.6, "h": 1.5}) }++    #[test]+    fn a_clean_footprint_and_its_symbol_pass() {+        let r = lint_library(&json!({"library": {+            "footprints": [{"name": "SOT23", "pads": [pad("1", -0.95, -1.1), pad("2", 0.95, -1.1), pad("3", 0.0, 1.1)], "courtyard": [-1.7, -2.0, 1.7, 2.0], "silk": [[0, 0]], "bodies": [{"h": 1.1}], "description": "SOT-23"}],+            "symbols": [{"name": "BJT", "pins": [{"number": "1"}, {"number": "2"}, {"number": "3"}], "footprint": "Lib:SOT23", "partNumber": "MMBT3904"}]}}));+        assert_eq!(r["ok"], true, "{r}");+        assert_eq!(r["checked"], 2);+        assert_eq!(r["findings"], json!([]));+    }++    #[test]+    fn the_shared_rules_fire_with_their_own_severity() {+        // the xcopy bug of altium-bridge#29: a copy 1270 mm off its origin+        let far = json!({"name": "BAT_FP_X", "pads": [pad("1", 1270.0, 0.0), pad("1", 1272.0, 0.0), json!({"number": "", "x": 1271.0, "y": 0.0, "w": 0.0, "h": 0.0})], "courtyard": null, "silk": [], "description": ""});+        let r = lint_library(&json!({"library": {"footprints": [far],+            "symbols": [{"name": "BAT", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "BAT_FP_X", "partNumber": ""}, {"name": "LOST", "pins": [{"number": "1"}], "footprint": "NOPE"}, {"name": "BARE", "pins": [], "footprint": ""}],+            "findings": [{"name": "X", "kind": "library", "rule": "tool_refuses_file", "severity": "error", "detail": "kicad-cli: bad token"}]}}));+        let rules: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|x| x["rule"].as_str().unwrap()).collect();+        for want in ["tool_refuses_file", "zero_size_pad", "unnumbered_pad", "duplicate_pad_number", "no_courtyard", "origin_off_part", "no_silkscreen", "no_description", "pin_without_pad", "no_part_number", "footprint_not_found", "no_pins", "no_footprint"] {+            assert!(rules.contains(&want), "{want} missing from {rules:?}");+        }+        // bodies was not sent, so no_3d_body is not evaluated+        assert!(!rules.contains(&"no_3d_body"));+        assert_eq!(r["ok"], false);+        assert!(r["findings"].as_array().unwrap().iter().all(|x| x["severity"] == severity(x["rule"].as_str().unwrap())));+        let off = r["findings"].as_array().unwrap().iter().find(|x| x["rule"] == "origin_off_part").unwrap();+        assert!(off["detail"].as_str().unwrap().starts_with("the origin is 1268.700 mm"), "{off}");+    }++    #[test]+    fn a_spare_pad_warns_and_a_mechanical_one_does_not() {+        let fp = json!({"name": "USB", "pads": [pad("1", 0.0, 0.0), pad("2", 0.65, 0.0), pad("S1", 3.0, 0.0), json!({"number": "", "x": -2.0, "y": 1.0, "w": 0.0, "h": 0.0, "hole": 0.65, "plated": false})]});+        let r = lint_library(&json!({"library": {"footprints": [fp], "symbols": [{"name": "USB", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "USB"}]}}));+        let f: Vec<(&str, &str)> = r["findings"].as_array().unwrap().iter().map(|x| (x["rule"].as_str().unwrap(), x["detail"].as_str().unwrap())).collect();+        assert_eq!(f, vec![("pad_without_pin", "pad S1 has no pin on USB")], "{r}");+        assert_eq!(r["ok"], true, "a warning does not fail the library");+        // a name filter, and unread items fail ok+        let r = lint_library(&json!({"library": {"footprints": [{"name": "A", "pads": []}, {"name": "B", "pads": [pad("1", 0.0, 0.0)]}], "unread": [{"name": "C", "error": "locked"}]}, "args": {"names": ["B"]}}));+        assert_eq!((r["checked"].clone(), r["errors"].clone(), r["ok"].clone()), (json!(1), json!(0), json!(false)));+    }+}
rust/vendor/adom-eda-checks/src/main.rs+1−1
@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> {     while let Some(flag) = it.next() {         let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;         match flag.as_str() {-            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" => {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" | "--library" => {                 let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };                 b[key] = read(val)?;             }
tests/run_verb_tests.py+3
@@ -470,6 +470,9 @@ t("fusion_foreground", "manual", {"reason": "battery check of the foreground ver t("fusion_foreground#noreason", "negative", {}, {"pred": "must_refuse"}, "a foreground with no reason is refused") t("fusion_list_installed_libraries", "daily", {"projectName": "Default Project", "depth": 2}, {"ok": True, "fields": ["libraries", "complete"]}, "the hub's .flbr libraries in one project") t("fusion_add_symbol_art", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/art/art-test.lbr", "symbolName": "TPS54202DDCR", "kicadSym": "C:/Users/olive/Documents/fusion-lane-tests/art/tps54202ddcr-outline.kicad_sym", "dryRun": True}, {"ok": True, "fields": ["data"]}, "the TPS54202 chip outline fitted to the symbol body (dry run)")+t("fusion_lint_library", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr"}, {"ok": True, "fields": ["findings", "checked", "engine"]}, "the imported LDO library judged by the engine")+t("fusion_update_footprints_from_library", "daily", dict(B, libraryPath="C:/Users/olive/Documents/fusion-lane-tests/boardgen.lbr"), {"ok": True, "fields": ["plan", "footprints", "differs"]}, "BQ25792's footprints against a test library: mostly library_not_found, a plan either way")+t("fusion_update_footprints_from_library#apply", "negative", dict(B, libraryPath="C:/Users/olive/Documents/fusion-lane-tests/boardgen.lbr", apply=True), {"pred": "must_refuse"}, "apply is refused: Fusion's UPDATE asks about each part in a dialog") t("fusion_build_board", "daily", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/build.brd", "parts": [{"ref": "R1", "footprint": "R0603", "x": 5, "y": 5}, {"ref": "Q1", "footprint": "SOT23", "x": 12, "y": 5}], "nets": {"GND": ["R1.2", "Q1.2"]}, "outline": {"rect": [0, 0, 18, 10], "cornerRadius": 1}, "open": False}, {"ok": True, "fields": ["pcbPath", "unknownPins"]}, "written offline (open:false keeps the battery from opening a design each run)") t("fusion_build_board#missing", "negative", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/bad.brd", "parts": [{"ref": "U1", "footprint": "NO_SUCH_FP", "x": 0, "y": 0}], "open": False}, {"pred": "must_refuse"}, "a footprint missing from the library refuses the whole build") t("fusion_test_board", "daily", {"libraryPath": BOARDLIB, "footprintName": "R0603;SOT23", "pcbPath": BOARDDIR + "/test.brd", "open": False}, {"ok": True, "fields": ["pcb", "placed", "saved"]}, "test.brd is made on the first run and appended to after (designators continue); written offline")