← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.12.6\ No newline at end of file+1.12.7\ No newline at end of file
CHANGELOG.md+21
@@ -3,6 +3,27 @@ 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.12.7 (2026-10-27, insiders)++The standard's fab exports, and read_footprint. 261 verbs.++- New:+  - `fusion_export_odb`: Fusion's own ODB++ (Electron.mfgodb with the JLCPCB CAM job), packed as zip, tgz or a folder;+  - `fusion_export_ipc356`: the IPC-D-356A netlist for bare-board electrical test (317 and 327 records, metric,+    long net names as NNAME aliases);+  - `fusion_export_gencad`: GenCAD 1.4 (outline, pads and padstacks, shapes, components with bottom parts mirrored,+    devices, signals, routes and vias);+  - `fusion_export_ipc2581`: IPC-2581 revision C, valid against the IPC-2581C schema (layers and stack-up,+    packages, components and BOM, nets, pads, traces, vias, the poured copper island by island, drills). It is+    mode ASSEMBLY: mask, paste and silkscreen are not in it, so a fab order still goes through ODB++ or Gerbers;+  - `fusion_read_footprint`: one footprint's pads, layers, courtyard box, graphics per layer and 3D packages, from a+    .lbr or (by `libId` "Lib:Name") from the libraries the open board embeds.+- Fab text is ASCII: unit signs in values are named (10kΩ becomes 10kohm in GenCAD and IPC-356, `\U+03A9` in DXF).+  Before, a board DXF with an Ω on the silkscreen failed with a codec error.+- The checks engine is now `adom-eda-checks` 0.1.2 (standard 0.4.3). Fusion passes only copper clearances to it,+  since 0.1.2's `min_clearance` also takes mask, dimension and restrict clearances (standard issue #13).+- Daily suite: 121 pass on DESKTOP-BAR6S0F; the 5 failures are the APS verbs without the APS sign-in.+ ## 1.12.6 (2026-10-27, insiders)  - The board verbs bring the named design's board forward when its schematic, 3D PCB or another document is in front,
bridge.json+12−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.12.6",+  "version": "1.12.7",   "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.",@@ -104,11 +104,15 @@     "fusion_export_eagle_source",     "fusion_export_f3d",     "fusion_export_fbx",+    "fusion_export_gencad",     "fusion_export_gerber",     "fusion_export_gerbers",     "fusion_export_iges",+    "fusion_export_ipc2581",+    "fusion_export_ipc356",     "fusion_export_lbr",     "fusion_export_obj",+    "fusion_export_odb",     "fusion_export_optimized_glb",     "fusion_export_sat",     "fusion_export_skp",@@ -187,6 +191,7 @@     "fusion_prefs_open",     "fusion_prewarm",     "fusion_prove_result",+    "fusion_read_footprint",     "fusion_read_rules",     "fusion_read_sheet_file",     "fusion_readiness",@@ -422,7 +427,12 @@       "return_path": 150,       "impedance": 150,       "current_capacity": 150,-      "fab_check": 150+      "fab_check": 150,+      "export_odb": 240,+      "export_ipc356": 120,+      "export_gencad": 120,+      "export_ipc2581": 150,+      "read_footprint": 90     },     "prefixes": [       {
contracts/describe.json+104
@@ -1679,6 +1679,25 @@       "summary": "Export to FBX mesh.",       "timeoutSeconds": 300     },+    {+      "example": {},+      "input": {+        "expectDocument": "required: the board's name",+        "outputDir": "folder on the Fusion machine (default %TEMP%/adom-gencad)"+      },+      "longRunning": false,+      "name": "fusion_export_gencad",+      "output": {+        "components": "int",+        "pads": "int",+        "path": "str",+        "shapes": "int",+        "signals": "int"+      },+      "statusVerb": null,+      "summary": "The board as GenCAD 1.4 (mm) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.",+      "timeoutSeconds": 180+    },     {       "example": {},       "input": {@@ -1728,6 +1747,46 @@       "summary": "Export to IGES.",       "timeoutSeconds": 300     },+    {+      "example": {},+      "input": {+        "expectDocument": "required: the board's name",+        "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc2581)"+      },+      "longRunning": false,+      "name": "fusion_export_ipc2581",+      "output": {+        "components": "int",+        "holes": "int",+        "nets": "int",+        "pads": "int",+        "path": "str",+        "pourIslands": "int",+        "poursUnfilled": "int",+        "traces": "int",+        "vias": "int"+      },+      "statusVerb": null,+      "summary": "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581): layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",+      "timeoutSeconds": 180+    },+    {+      "example": {},+      "input": {+        "expectDocument": "required: the board's name",+        "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc356)"+      },+      "longRunning": false,+      "name": "fusion_export_ipc356",+      "output": {+        "nets": "int",+        "path": "str",+        "records": "{throughHole, smd}"+      },+      "statusVerb": null,+      "summary": "The board's IPC-D-356A netlist for bare-board electrical test (the standard's export_ipc356): a 317 record per drilled pad and via, a 327 record per SMD pad, with net, position, size, access side and mask, metric (UNITS CUST 1); net names over 14 characters as NNAME aliases.",+      "timeoutSeconds": 180+    },     {       "example": {         "outputPath": "C:/Temp/TJA1050.scr"@@ -1763,6 +1822,28 @@       "summary": "Export to OBJ mesh.",       "timeoutSeconds": 300     },+    {+      "example": {+        "compression": "zip"+      },+      "input": {+        "compression": "zip|tgz|none, default zip",+        "expectDocument": "required: the board's name",+        "outputDir": "folder on the Fusion machine (default %TEMP%/adom-odb)"+      },+      "longRunning": false,+      "name": "fusion_export_odb",+      "output": {+        "camJob": "str",+        "files": "int",+        "layers": "[str] (the matrix)",+        "nets": "int",+        "path": "str"+      },+      "statusVerb": null,+      "summary": "The board as ODB++ through Fusion's own exporter (the standard's export_odb): copper, mask, paste, silkscreen, dielectrics, drill and rout, the netlist (eda/data) and the components in one tree, packed as asked. Fusion reports an error after writing the tree; the bridge finds the tree by its matrix, checks it and packs it.",+      "timeoutSeconds": 180+    },     {       "example": {         "outputPath": "C:/out/board.glb"@@ -3285,6 +3366,29 @@       "summary": "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render. restarts[] {at, why, kind} lists any restart the chain performed (only with allowRestart). prove.planned / prove.skipped say whether evidence frames were captured and why (AI threads bind only by default).",       "timeoutSeconds": 20     },+    {+      "example": {},+      "input": {+        "expectDocument": "the board's name, for libId",+        "libId": "'Library:Package' from the board's embedded libraries",+        "libraryPath": "a .lbr on the Fusion machine (with name)",+        "name": "the package's name"+      },+      "longRunning": false,+      "name": "fusion_read_footprint",+      "output": {+        "attributes": "{mount}",+        "courtyardMm": "[x1, y1, x2, y2]",+        "graphics": "{layer: count}",+        "layers": "[str]",+        "models": "[{name, urn, type}]",+        "name": "str",+        "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]"+      },+      "statusVerb": null,+      "summary": "Every detail of one library footprint (the standard's read_footprint): pads with number, type, shape, position, size, drill, layers and turn; the layers used; the courtyard box (component exclude layers 39/40, else pads and placement outline); graphics per layer; the 3D packages that instance it. From a .lbr (libraryPath + name) or, by libId 'Lib:Name', from the libraries the open board embeds.",+      "timeoutSeconds": 60+    },     {       "example": {         "expectDocument": "fusion-routing"
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.12.6"+version = "1.12.7" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.6"+version = "1.12.7" edition = "2021"  [dependencies]
describe.py+20
@@ -251,6 +251,26 @@ _T = [      {"expectDocument": "required: the board's name", "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)", "process": "optional record process", "fab": "a record instead", "limits": "{minTrack, minClearance, ...} mm"},      {"measured": "{...}", "outsideLimits": "[{limit, boardMm, limitMm, at, source}]", "fab": "the record", "engine": "str"},      120, None, False, {'vendor': 'jlcpcb'}),+    ('fusion_export_odb', "The board as ODB++ through Fusion's own exporter (the standard's export_odb): copper, mask, paste, silkscreen, dielectrics, drill and rout, the netlist (eda/data) and the components in one tree, packed as asked. Fusion reports an error after writing the tree; the bridge finds the tree by its matrix, checks it and packs it.",+     {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-odb)", "compression": "zip|tgz|none, default zip"},+     {"path": "str", "layers": "[str] (the matrix)", "files": "int", "nets": "int", "camJob": "str"},+     180, None, False, {'compression': 'zip'}),+    ('fusion_export_ipc356', "The board's IPC-D-356A netlist for bare-board electrical test (the standard's export_ipc356): a 317 record per drilled pad and via, a 327 record per SMD pad, with net, position, size, access side and mask, metric (UNITS CUST 1); net names over 14 characters as NNAME aliases.",+     {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc356)"},+     {"path": "str", "records": "{throughHole, smd}", "nets": "int"},+     180, None, False, {}),+    ('fusion_export_gencad', "The board as GenCAD 1.4 (mm) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.",+     {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-gencad)"},+     {"path": "str", "components": "int", "signals": "int", "pads": "int", "shapes": "int"},+     180, None, False, {}),+    ('fusion_read_footprint', "Every detail of one library footprint (the standard's read_footprint): pads with number, type, shape, position, size, drill, layers and turn; the layers used; the courtyard box (component exclude layers 39/40, else pads and placement outline); graphics per layer; the 3D packages that instance it. From a .lbr (libraryPath + name) or, by libId 'Lib:Name', from the libraries the open board embeds.",+     {"libraryPath": "a .lbr on the Fusion machine (with name)", "name": "the package's name", "libId": "'Library:Package' from the board's embedded libraries", "expectDocument": "the board's name, for libId"},+     {"name": "str", "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]", "layers": "[str]", "courtyardMm": "[x1, y1, x2, y2]", "graphics": "{layer: count}", "attributes": "{mount}", "models": "[{name, urn, type}]"},+     60, None, False, {}),+    ('fusion_export_ipc2581', "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581): layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",+     {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc2581)"},+     {"path": "str", "components": "int", "nets": "int", "pads": "int", "traces": "int", "vias": "int", "holes": "int", "pourIslands": "int", "poursUnfilled": "int"},+     180, None, False, {}),     ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",      {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},      {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),
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.12.6"+DASH_VERSION="1.12.7" 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.12.6",+  "version": "1.12.7",   "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.12.6",+  "version": "1.12.7",   "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": {@@ -143,4 +143,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
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.12.6"+version = "1.12.7" edition = "2021" license = "MIT" 
rust/bridge/src/checks.rs+1−1
@@ -42,6 +42,6 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val         if let Some(v) = r[key]["verb"].as_str().map(str::to_string) { r[key]["verb"] = json!(format!("fusion_{v}")); }     }     for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } }-    r["engine"] = json!("adom-eda-checks 0.1.1 (EDA Bridge Standard 0.4.2, vendored)");+    r["engine"] = json!("adom-eda-checks 0.1.2 (EDA Bridge Standard 0.4.3, vendored)");     r }
rust/bridge/src/docs.rs+34−1
@@ -2,7 +2,7 @@ //! Fusion's main thread). document_info keeps the add-in's reply and adds the standard's fields. use serde_json::{json, Value}; const SOURCE: &str = include_str!("docs_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d" | "read_footprint") } fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let s = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(cmd).unwrap());@@ -12,6 +12,7 @@ fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     match cmd {+        "read_footprint" => read_footprint(args, pass),         "document_info" => {             let mut old = pass("document_info", args.clone());             let std_ = script(cmd, args, pass);@@ -44,3 +45,35 @@ pub fn library_views_args(args: &Value) -> Value {     if let Some(v) = args["views"].as_array() { a["views"] = json!(v.iter().map(|x| if x == "device" { json!("component") } else { x.clone() }).collect::<Vec<_>>()); }     a }++/// The standard's read_footprint: from a .lbr (`libraryPath` + `name`), or by `libId` "Lib:Name"+/// from the libraries the open board embeds (`expectDocument` names the board; Fusion keeps no+/// global library table, so a design's own copy is the one its parts use).+fn read_footprint(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let path = args["libraryPath"].as_str().or(args["filePath"].as_str()).unwrap_or("").replace('\\', "/");+    let lib_id = args["libId"].as_str().unwrap_or("");+    let (lib, name) = match lib_id.split_once(':') { Some((l, n)) => (Some(l.to_string()), n.to_string()), None => (None, args["name"].as_str().unwrap_or(lib_id).to_string()) };+    if name.is_empty() {+        return json!({"success": false, "errorCode": "bad_args", "error": "name (with libraryPath) or libId \"Lib:Name\" is required",+                      "_hint": "fusion_read_footprint {libraryPath:'C:/libs/my.lbr', name:'SOIC-8'} reads a .lbr; {libId:'Lib:Name', expectDocument:'<board>'} reads the library the open board embeds."});+    }+    let (xml, source) = if !path.is_empty() {+        match std::fs::read_to_string(&path) { Ok(t) => (t, json!({"libraryPath": path})), Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string(), "libraryPath": path}) }+    } else {+        if args["expectDocument"].as_str().unwrap_or("").is_empty() {+            return json!({"success": false, "errorCode": "bad_args", "error": "libId without libraryPath reads the open board's libraries: expectDocument (the board's name) is required"});+        }+        let b = crate::routing::call("board_source", &json!({"expectDocument": args["expectDocument"]}), pass);+        match b["source"].as_str() { Some(t) => (t.to_string(), json!({"board": b["documentName"]})), None => return b }+    };+    match fusion_lbr::read_footprint(&xml, lib.as_deref(), &name) {+        Ok(mut v) => {+            let n = v["pads"].as_array().map(|a| a.len()).unwrap_or(0);+            v["success"] = json!(true);+            v["source"] = source;+            v["_hint"] = json!(format!("{n} pad(s). Pad numbers are the package's pad names; a device's connects map them to the symbol's pins (fusion_library_parts lists the devices). 3D models are named by their Fusion package3d and URN: Fusion places them itself, so no offset, scale or turn is stored in the library."));+            v+        }+        Err(e) => json!({"success": false, "errorCode": "footprint_not_found", "error": e.to_string()}),+    }+}
rust/bridge/src/routing.rs+4−2
@@ -3,11 +3,13 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let mut args_v = args.clone();+    if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(),-        serde_json::to_string(&args.to_string()).unwrap(),+        serde_json::to_string(&args_v.to_string()).unwrap(),         serde_json::to_string(cmd).unwrap());     let r = pass("run_modeling_script", json!({"script":script,"expectDocument":args["expectDocument"]}));     if r["success"].as_bool() == Some(false) { return r; }
rust/bridge/src/routing_script.py+566−2
@@ -4,6 +4,7 @@ No open-file rewrites, UI clicks, add-in replacement or application restart. The geometry preflight is deliberately conservative and is NOT Autodesk DRC. """ import collections+import datetime import hashlib import json import math@@ -1586,6 +1587,7 @@ class Dxf:                 continue             dx, dy = math.sin(a) * h * 1.6 * i, -math.cos(a) * h * 1.6 * i             x, y = (p[0] + dx) * self.k, (p[1] + dy) * self.k+            line = ''.join(c if ord(c) < 128 else '\\U+%04X' % ord(c) for c in line)  # R12 is not UTF-8: an ohm sign is written \\U+03A9             tags = [(0, 'TEXT'), (8, 'L'), (10, x), (20, y), (30, 0.0), (40, h * self.k), (1, line), (50, rot)]             if (hz, v) != ('left', 'bottom'):                 tags += [(72, self.H.get(hz, 0)), (11, x), (21, y), (31, 0.0), (73, self.V.get(v, 1))]@@ -1747,7 +1749,7 @@ def board_dxf(app, root, board, meta, args):                         d.text(f(_f(g, 'x'), _f(g, 'y')), _f(g, 'size', 1.27), txt, (deg + (-trot if mirrored else trot)) % 360,                                g.get('align', 'bottom-left'), 'S' in (g.get('rot') or ''))         path = out_dir / ('%s-%s.dxf' % (stem, label.replace('.', '_')))-        path.write_text(d.text_out())+        path.write_text(d.text_out(), encoding='ascii')         n_ent = len([x for x in d.out if x.startswith('0\n') and not x.startswith('0\nVERTEX') and not x.startswith('0\nSEQEND')])         files.append({'layer': label, 'path': path.as_posix(), 'entities': n_ent})         if not n_ent:@@ -2105,10 +2107,568 @@ def check_inputs(app, root, board, meta, args):             if v is not None:                 std.append({'kind': 'clearance', 'clearanceMm': round(v, 4), 'name': r_.get('name'), 'enabled': True})     return {'success': True, 'mutated': False, **meta, 'board': state(board, meta), 'stack': layer_stack(board),-            'pours': {'zones': zones(board)}, 'rules': {'rules': std + [dict(x, kind='fusion:' + str(x.get('kind'))) for x in rules.get('rules', [])]},+            # only the copper clearances: Fusion's other rule kinds also say "Clearance" (Dimension,+            # Solder-Mask, Restrict, Keepout) and the engine takes any such kind's value+            'pours': {'zones': zones(board)}, 'rules': {'rules': std},             'silk': rd_silk_state(root, board, {})}  +def _cam_job(layers):+    """The add-in's CAM job for the board's layer count (its JLCPCB jobs carry an ODB++ output)."""+    import os+    name = 'jlcpcb-%dlayers.cam' % (2 if layers <= 2 else 4)+    for base in (r'%APPDATA%\Autodesk\FusionAddins\AdomBridge', r'%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\AdomBridge',+                 r'%APPDATA%\Autodesk\Autodesk Fusion\API\AddIns\AdomBridge'):+        p_ = os.path.join(os.path.expandvars(base), 'resources', 'cam', name)+        if os.path.exists(p_):+            return p_.replace('\\', '/')+    fail('no_cam_job', 'The add-in\'s CAM job %s is not installed (fusion_addin_status / reinstall the add-in)' % name)+++def export_odb(app, root, board, meta, args):+    """ODB++ through Fusion's own exporter (Electron.mfgodb with the add-in's CAM job). Fusion writes a+    complete ODB++ tree (matrix, steps/pcb with each layer's features, eda/data, components) and then+    reports 'ODB++ output directory does not exist or is empty' from a later step (live 2026-10-27):+    the tree is found by its matrix file, checked, and packed as asked."""+    import os, shutil, tarfile+    layers = len(Layers(board).order) or 2+    cam = _cam_job(layers)+    work = Path(tempfile.mkdtemp(prefix='adom-odb-'))+    note = None+    try:+        app.executeTextCommand('Electron.mfgodb %s %s' % (work.as_posix(), cam))+    except Exception as e:+        note = str(e)+    trees = [Path(d) for d, _, fs in os.walk(work) if d.endswith('.odb') and (Path(d) / 'matrix' / 'matrix').exists()]+    if not trees:+        shutil.rmtree(work, ignore_errors=True)+        fail('export_failed', 'Fusion wrote no ODB++ tree%s' % ((': ' + note) if note else ''))+    tree = trees[0]+    matrix = (tree / 'matrix' / 'matrix').read_text(errors='replace')+    names = re.findall(r'NAME=(\S+)', matrix)+    n_files = sum(len(fs) for _, _, fs in os.walk(tree))+    eda = (tree / 'steps' / 'pcb' / 'eda' / 'data')+    nets = len(re.findall(r'^NET ', eda.read_text(errors='replace'), re.M)) if eda.exists() else 0+    out_dir = Path(args.get('outputDir') or (Path(tempfile.gettempdir()) / 'adom-odb'))+    out_dir.mkdir(parents=True, exist_ok=True)+    stem = re.sub(r'[^A-Za-z0-9_.-]+', '_', meta.get('documentName') or 'board')+    comp = (args.get('compression') or 'zip').lower()+    if comp == 'zip':+        dest = out_dir / ('%s.odb.zip' % stem)+        if dest.exists(): dest.unlink()+        with zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) as z:+            for d, _, fs in os.walk(tree):+                for f in fs:+                    full = Path(d) / f+                    z.write(full, (Path(stem + '.odb') / full.relative_to(tree)).as_posix())+    elif comp == 'tgz':+        dest = out_dir / ('%s.odb.tgz' % stem)+        with tarfile.open(dest, 'w:gz') as t:+            t.add(tree, arcname=stem + '.odb')+    elif comp == 'none':+        dest = out_dir / ('%s.odb' % stem)+        if dest.exists(): shutil.rmtree(dest)+        shutil.copytree(tree, dest)+    else:+        shutil.rmtree(work, ignore_errors=True)+        fail('bad_args', 'compression is zip, tgz or none')+    shutil.rmtree(work, ignore_errors=True)+    return {'success': True, 'mutated': False, **meta, 'path': dest.as_posix(), 'format': 'ODB++', 'compression': comp,+            'layers': names, 'files': n_files, 'nets': nets, 'camJob': cam.rsplit('/', 1)[-1],+            '_hint': 'Fusion\'s own ODB++ (version from its CAM job). The matrix lists %d layers; eda/data carries the netlist (%d nets) and the component layers the placement.' % (len(names), nets)}+++def ipc356_lines(model, job):+    """IPC-D-356A in its fixed 80-column form, metric (UNITS CUST 1: 0.001 mm): a 317 record per+    drilled pad and via, a 327 record per SMD pad, net names over 14 characters as NNAME aliases."""+    u = lambda v: int(round(float(v) * 1000))+    lines = ['C  IPC-D-356A netlist from Fusion (fusion-bridge)', 'P  JOB   %s' % ascii_text(job)[:60], 'P  UNITS CUST 1', 'P  DIM   N']+    alias, n_alias = {}, 0+    def net_field(n):+        nonlocal n_alias+        n = ascii_text(n or 'N/C')+        if len(n) <= 14:+            return n+        if n not in alias:+            n_alias += 1+            alias[n] = 'NNAME%d' % n_alias+            lines.append('P  %s %s' % (alias[n], n))+        return alias[n]+    def rec(code, net, ref, pin, drill, plated, access, x, y, sx, sy, rot, mask):+        r = [' '] * 80+        def put(col, txt):+            for k, ch in enumerate(txt):+                if col - 1 + k < 80:+                    r[col - 1 + k] = ch+        put(1, code)+        put(4, net_field(net).ljust(14)[:14])+        put(21, ref.ljust(6)[:6])+        put(27, '-')+        put(28, str(pin).ljust(4)[:4])+        if drill:+            put(33, 'D%04d' % min(u(drill), 9999))+            put(38, 'P' if plated else 'U')+        put(39, 'A%02d' % access)+        put(42, 'X%s%06d' % ('-' if x < 0 else '+', min(abs(u(x)), 999999)))+        put(50, 'Y%s%06d' % ('-' if y < 0 else '+', min(abs(u(y)), 999999)))+        put(58, 'X%04d' % min(u(sx), 9999))+        put(63, 'Y%04d' % min(u(sy), 9999))+        put(68, 'R%03d' % (int(round(rot or 0)) % 360))+        put(73, 'S%d' % mask)+        return ''.join(r).rstrip()+    body = []+    for p_ in model['pads']:+        drilled = bool(p_.get('drill'))+        side = 0 if drilled else (1 if p_.get('layer') == 'F.Cu' else 2)+        body.append(rec('317' if drilled else '327', p_.get('net'), p_.get('ref') or '', p_.get('name') or '', p_.get('drill') or 0, True,+                        side, p_['x'], p_['y'], p_.get('sizeX') or 0, p_.get('sizeY') or 0, p_.get('rotation') or 0, 0 if drilled else side))+    for v in model['vias']:+        body.append(rec('317', v.get('net'), 'VIA', '', v.get('drill') or 0, True, 0, v['position']['x'], v['position']['y'], v.get('size') or 0, v.get('size') or 0, 0, 3))+    return lines + body + ['999']+++def export_ipc356(app, root, board, meta, args):+    model, _ = board_model(board, meta)+    stem = re.sub(r'[^A-Za-z0-9_.-]+', '_', meta.get('documentName') or 'board')+    out_dir = Path(args.get('outputDir') or (Path(tempfile.gettempdir()) / 'adom-ipc356'))+    out_dir.mkdir(parents=True, exist_ok=True)+    lines = ipc356_lines(model, stem)+    path = out_dir / ('%s.ipc' % stem)+    path.write_text('\r\n'.join(lines) + '\r\n', encoding='ascii')+    n317 = sum(1 for l in lines if l.startswith('317')); n327 = sum(1 for l in lines if l.startswith('327'))+    return {'success': True, 'mutated': False, **meta, 'path': path.as_posix(), 'format': 'IPC-D-356A', 'units': 'mm (CUST 1)',+            'records': {'throughHole': n317, 'smd': n327}, 'nets': len({p_.get('net') for p_ in model['pads'] if p_.get('net')}),+            '_hint': 'One record per pad and via: 317 drilled (vias as VIA), 327 SMD, with net, position, size and access side; net names over 14 characters are NNAME aliases. For flying-probe or bed-of-nails test.'}+++def ipc2581_xml(board, meta, model=None, stack=None, version=''):+    """IPC-2581 revision C (mm) for assembly and test: the layers and stack-up, the outline, one+    package per footprint with its pins, the components and BOM, the nets with their pins, and on+    each copper layer the pads (with their pin), traces, arcs and vias, plus the drills. Pours are+    left out: Fusion's ULP cannot read a computed pour (S.polygons fails on a polygonpour, live+    2026-10-27), and a pour drawn as its outline would be wrong copper."""+    NS = 'http://webstds.ipc.org/2581'+    E = ET.Element+    S = ET.SubElement+    f = lambda v: ('%.4f' % float(v)).rstrip('0').rstrip('.') or '0'+    name = re.sub(r'[^A-Za-z0-9_.-]+', '_', ascii_text(meta.get('documentName') or 'board')) or 'board'+    model = model or board_model(board, meta)[0]+    stack = stack or layer_stack(board)+    copper = list(model['copperLayers'])+    lname = {}+    for i, c in enumerate(copper):+        lname[c] = 'TOP' if i == 0 else ('BOTTOM' if i == len(copper) - 1 else 'INNER%d' % i)+    root = E('IPC-2581', {'revision': 'C', 'xmlns': NS})+    content = S(root, 'Content', roleRef='Owner')+    S(content, 'FunctionMode', mode='ASSEMBLY')+    S(content, 'StepRef', name=name)+    layers = [(lname[c], 'SIGNAL', 'TOP' if i == 0 else ('BOTTOM' if i == len(copper) - 1 else 'INTERNAL')) for i, c in enumerate(copper)]+    layers += [('DRILL_TOP_BOTTOM', 'DRILL', 'ALL')]+    for ln, _, _ in layers:+        S(content, 'LayerRef', name=ln)+    S(content, 'BomRef', name=name + '_BOM')+    dict_std = S(content, 'DictionaryStandard', units='MILLIMETER')+    prims = {}++    def prim(shape, w, h=None):+        if shape == 'round' or h is None:+            key = ('C', round(float(w), 4))+        else:+            key = ('R', round(float(w), 4), round(float(h), 4))+        if key not in prims:+            pid = ('CIRCLE_%s' % f(key[1]) if key[0] == 'C' else 'RECT_%sx%s' % (f(key[1]), f(key[2]))).replace('.', '_')+            prims[key] = pid+            ent = S(dict_std, 'EntryStandard', id=pid)+            if key[0] == 'C':+                S(ent, 'Circle', diameter=f(key[1]))+            else:+                S(ent, 'RectCenter', width=f(key[1]), height=f(key[2]))+        return prims[key]++    lh = S(root, 'LogisticHeader')+    S(lh, 'Role', id='Owner', roleFunction='SENDER')+    S(lh, 'Enterprise', id='Adom', code='NONE')+    S(lh, 'Person', name='fusion-bridge', enterpriseRef='Adom', roleRef='Owner')+    now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()+    hr = S(root, 'HistoryRecord', number='1', origination=now, software='fusion-bridge', lastChange=now)+    fr = S(hr, 'FileRevision', fileRevisionId='1', comment='from Autodesk Fusion')+    sw = S(fr, 'SoftwarePackage', name='fusion-bridge', revision=version or '1', vendor='Adom')+    S(sw, 'Certification', certificationStatus='SELFTEST')++    comps = model['components']+    bom = S(root, 'Bom', name=name + '_BOM')+    bh = S(bom, 'BomHeader', assembly=name, revision='1')+    S(bh, 'StepRef', name=name)+    groups = {}+    for c in comps:+        groups.setdefault((c.get('pattern') or '', c.get('value') or ''), []).append(c)+    for (pat, val), cs in sorted(groups.items()):+        bi = S(bom, 'BomItem', OEMDesignNumberRef=ascii_text('%s_%s' % (pat, val)).strip('_') or 'PART', quantity=str(len(cs)), category='ELECTRICAL',+               description=ascii_text(val))+        for c in cs:+            S(bi, 'RefDes', name=ascii_text(c['ref']), packageRef=ascii_text(pat), populate='true', layerRef='TOP' if c.get('side') != 'bottom' else 'BOTTOM')+        ch = S(bi, 'Characteristics', category='ELECTRICAL')+        if val:+            S(ch, 'Textual', definitionSource='fusion', textualCharacteristicName='Value', textualCharacteristicValue=ascii_text(val))++    ecad = S(root, 'Ecad', name=name)+    S(ecad, 'CadHeader', units='MILLIMETER')+    cad = S(ecad, 'CadData')+    for ln, fn, side in layers:+        S(cad, 'Layer', name=ln, layerFunction=fn, side=side, polarity='POSITIVE')+    total = (stack.get('totalUm') or 1600.0) / 1000.0+    st = S(cad, 'Stackup', name='PRIMARY', overallThickness=f(total), tolPlus='0', tolMinus='0', whereMeasured='METAL', stackupStatus='PROPOSED')+    sg = S(st, 'StackupGroup', name='PRIMARY', thickness=f(total), tolPlus='0', tolMinus='0')+    seq = 0+    by_name = {l['name']: l for l in stack.get('layers') or []}+    for i, c in enumerate(copper):+        info = by_name.get(c, {})+        seq += 1+        S(sg, 'StackupLayer', layerOrGroupRef=lname[c], thickness=f((info.get('copperUm') or 35.0) / 1000.0), tolPlus='0', tolMinus='0', sequence=str(seq))+    step = S(cad, 'Step', name=name)++    vstacks = {}+    for v in model['vias']:+        key = (round(v.get('size') or 0, 4), round(v.get('drill') or 0, 4))+        if key not in vstacks:+            vn = ('VIA_%s_%s' % (f(key[0]), f(key[1]))).replace('.', '_')+            vstacks[key] = vn+            ps = S(step, 'PadStackDef', name=vn)+            S(ps, 'PadstackHoleDef', name='H' + vn, diameter=f(key[1]), platingStatus='VIA', plusTol='0', minusTol='0', x='0', y='0')+            for c in copper:+                pd = S(ps, 'PadstackPadDef', layerRef=lname[c], padUse='REGULAR')+                S(pd, 'Location', x='0', y='0')+                S(pd, 'StandardPrimitiveRef', id=prim('round', key[0]))+    S(step, 'Datum', x='0', y='0')+    prof = S(step, 'Profile')++    def poly(parent, edges, tag='Polygon'):+        pg = S(parent, tag)+        if not edges:+            return pg+        S(pg, 'PolyBegin', x=f(edges[0]['start'][0]), y=f(edges[0]['start'][1]))+        for e in edges:+            if e['kind'] == 'arc':+                cw = e.get('clockwise')+                if cw is None:+                    (sx, sy), (ex, ey), (cx, cy) = e['start'], e['end'], e['center']+                    cw = ((sx - cx) * (ey - cy) - (sy - cy) * (ex - cx)) < 0+                S(pg, 'PolyStepCurve', x=f(e['end'][0]), y=f(e['end'][1]), centerX=f(e['center'][0]), centerY=f(e['center'][1]), clockwise='true' if cw else 'false')+            else:+                S(pg, 'PolyStepSegment', x=f(e['end'][0]), y=f(e['end'][1]))+        return pg++    poly(prof, list(model['outlineEdges']))+    for cut in model['outlineCutouts']:+        poly(prof, cut['edges'], 'Cutout')++    pk = {(l.get('name'), p_.get('name')): p_ for l in board.findall('libraries/library') for p_ in l.findall('packages/package')}+    el_key = {e.get('name'): (e.get('library'), e.get('package')) for e in board.findall('elements/element')}+    made = {}+    for c in comps:+        key = el_key.get(c['ref'])+        pkg = pk.get(key)+        pat = ascii_text(c.get('pattern') or (key or ('', ''))[1])+        if pkg is None or pat in made:+            continue+        pins = [g for g in pkg if g.tag in ('pad', 'smd')]+        th = any(g.tag == 'pad' for g in pins)+        made[pat] = 'THMT' if th else 'SMT'+        P = S(step, 'Package', name=pat, type='OTHER', pinOneOrientation='OTHER')+        xs = [_f(g, 'x') for g in pins] or [0]+        ys = [_f(g, 'y') for g in pins] or [0]+        half = max([max(_f(g, 'dx'), _f(g, 'dy'), _f(g, 'diameter'), _f(g, 'drill') * 2) / 2 for g in pins] or [0.5])+        x0, x1, y0, y1 = min(xs) - half, max(xs) + half, min(ys) - half, max(ys) + half+        out = S(P, 'Outline')+        pg = S(out, 'Polygon')+        S(pg, 'PolyBegin', x=f(x0), y=f(y0))+        for x, y in ((x1, y0), (x1, y1), (x0, y1), (x0, y0)):+            S(pg, 'PolyStepSegment', x=f(x), y=f(y))+        S(out, 'LineDesc', lineEnd='ROUND', lineWidth='0.1')+        for g in pins:+            pin = S(P, 'Pin', number=ascii_text(g.get('name')), type='THRU' if g.tag == 'pad' else 'SURFACE')+            rot = float((re.search(r'R(-?[\d.]+)', g.get('rot') or 'R0') or [0, 0])[1]) % 360+            if rot:+                S(pin, 'Xform', rotation=f(rot))+            S(pin, 'Location', x=f(_f(g, 'x')), y=f(_f(g, 'y')))+            if g.tag == 'smd':+                S(pin, 'StandardPrimitiveRef', id=prim('rect', _f(g, 'dx'), _f(g, 'dy')))+            else:+                S(pin, 'StandardPrimitiveRef', id=prim('round', max(_f(g, 'diameter'), _f(g, 'drill') * 1.5)))+    for c in comps:+        pat = ascii_text(c.get('pattern') or '')+        if pat not in made:+            continue+        bottom = c.get('side') == 'bottom'+        C = S(step, 'Component', refDes=ascii_text(c['ref']), packageRef=pat, part=ascii_text('%s_%s' % (c.get('pattern') or '', c.get('value') or '')).strip('_') or 'PART',+              layerRef='BOTTOM' if bottom else 'TOP', mountType=made[pat])+        rot = float(c.get('rotation') or 0) % 360+        if rot or bottom:+            xf = S(C, 'Xform')+            if rot:+                xf.set('rotation', f(rot))+            if bottom:+                xf.set('mirror', 'true')+        S(C, 'Location', x=f(c['x']), y=f(c['y']))+    nets = {}+    for p_ in model['pads']:+        if p_.get('net') and p_.get('ref'):+            nets.setdefault(p_['net'], []).append(p_)+    for n in sorted(nets):+        ln_ = S(step, 'LogicalNet', name=ascii_text(n))+        for p_ in nets[n]:+            S(ln_, 'PinRef', componentRef=ascii_text(p_['ref']), pin=ascii_text(p_['name']))+    counts = {'pads': 0, 'traces': 0, 'arcs': 0, 'vias': 0, 'holes': 0, 'pourIslands': 0, 'poursUnfilled': 0}+    pours = zones(board)+    counts['poursUnfilled'] = sum(1 for z in pours if not z['filled'])+    for c in copper:+        lf = S(step, 'LayerFeature', layerRef=lname[c])+        sets = {}++        def set_for(net, kind):+            k = (net or '', kind)+            if k not in sets:+                a = {'net': ascii_text(net)} if net else {}+                if kind == 'pad':+                    a['padUsage'] = 'TERMINATION'+                elif kind == 'via':+                    a['padUsage'] = 'VIA'+                sets[k] = S(lf, 'Set', a)+            return sets[k]++        for p_ in model['pads']:+            if p_.get('layer') not in (c, '*.Cu'):+                continue+            pd = S(set_for(p_.get('net'), 'pad'), 'Pad')+            rot = float(p_.get('rotation') or 0) % 360+            if rot:+                S(pd, 'Xform', rotation=f(rot))+            S(pd, 'Location', x=f(p_['x']), y=f(p_['y']))+            S(pd, 'StandardPrimitiveRef', id=prim(p_.get('shape'), p_.get('sizeX') or 0, p_.get('sizeY') or 0) if p_.get('shape') != 'round' else prim('round', p_.get('sizeX') or 0))+            if p_.get('ref'):+                S(pd, 'PinRef', componentRef=ascii_text(p_['ref']), pin=ascii_text(p_['name']))+            counts['pads'] += 1+        for v in model['vias']:+            if c not in (v.get('layers') or copper) and not (len(v.get('layers') or []) == 2 and copper.index(c) >= copper.index(v['layers'][0]) and copper.index(c) <= copper.index(v['layers'][1])):+                continue+            key = (round(v.get('size') or 0, 4), round(v.get('drill') or 0, 4))+            pd = S(set_for(v.get('net'), 'via'), 'Pad', padstackDefRef=vstacks[key])+            S(pd, 'Location', x=f(v['position']['x']), y=f(v['position']['y']))+            S(pd, 'StandardPrimitiveRef', id=prim('round', key[0]))+            counts['vias'] += 1+        for t in model['tracks']:+            if t.get('layer') != c:+                continue+            fe = S(set_for(t.get('net'), 'trace'), 'Features')+            ln = S(fe, 'Line', startX=f(t['start']['x']), startY=f(t['start']['y']), endX=f(t['end']['x']), endY=f(t['end']['y']))+            S(ln, 'LineDesc', lineEnd='ROUND', lineWidth=f(t['width']))+            counts['traces'] += 1+        for a in model['arcs']:+            if a.get('layer') != c:+                continue+            cx, cy, rr = a['center']['x'], a['center']['y'], a['radius']+            s0, s1 = math.radians(a['startAngle']), math.radians(a['endAngle'])+            fe = S(set_for(a.get('net'), 'trace'), 'Features')+            ar = S(fe, 'Arc', startX=f(cx + rr * math.cos(s0)), startY=f(cy + rr * math.sin(s0)), endX=f(cx + rr * math.cos(s1)), endY=f(cy + rr * math.sin(s1)),+                   centerX=f(cx), centerY=f(cy), clockwise='false')+            S(ar, 'LineDesc', lineEnd='ROUND', lineWidth=f(a['width']))+            counts['arcs'] += 1+        for z in pours:+            if z['layer'] != c:+                continue+            for fill in z['fills']:+                if len(fill['outline']) < 3:+                    continue+                fe = S(set_for(z['net'], 'pour'), 'Features')+                ct = S(fe, 'Contour')+                for tag, ring in [('Polygon', fill['outline'])] + [('Cutout', h) for h in fill['holes'] if len(h) >= 3]:+                    pg = S(ct, tag)+                    S(pg, 'PolyBegin', x=f(ring[0][0]), y=f(ring[0][1]))+                    for x, y in ring[1:] + ring[:1]:+                        S(pg, 'PolyStepSegment', x=f(x), y=f(y))+                counts['pourIslands'] += 1+    lf = S(step, 'LayerFeature', layerRef='DRILL_TOP_BOTTOM')+    hn = 0+    drills = [(p_.get('net'), p_['x'], p_['y'], p_['drill'], 'PLATED') for p_ in model['pads'] if p_.get('drill')]+    drills += [(v.get('net'), v['position']['x'], v['position']['y'], v['drill'], 'VIA') for v in model['vias']]+    drills += [(None, h['x'], h['y'], h.get('drill') or h.get('diameter') or 0, 'NONPLATED') for h in model['holes']]+    hsets = {}+    for net, x, y, d, plating in drills:+        k = net or ''+        if k not in hsets:+            hsets[k] = S(lf, 'Set', {'net': ascii_text(net)} if net else {})+        hn += 1+        S(hsets[k], 'Hole', name='H%d' % hn, diameter=f(d), platingStatus=plating, plusTol='0', minusTol='0', x=f(x), y=f(y))+    counts['holes'] = hn+    ET.indent(root) if hasattr(ET, 'indent') else None+    text = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding='unicode') + '\n'+    return text, dict(counts, components=len(comps), nets=len(nets), packages=len(made), layers=len(layers))+++def export_ipc2581(app, root, board, meta, args):+    text, counts = ipc2581_xml(board, meta, version=args.get('_bridgeVersion') or '')+    stem = re.sub(r'[^A-Za-z0-9_.-]+', '_', meta.get('documentName') or 'board')+    out_dir = Path(args.get('outputDir') or (Path(tempfile.gettempdir()) / 'adom-ipc2581'))+    out_dir.mkdir(parents=True, exist_ok=True)+    path = out_dir / ('%s.xml' % stem)+    path.write_text(text, encoding='utf-8')+    hint = ('IPC-2581 revision C, valid against the IPC-2581C schema: layers and stack-up, outline, packages and components, BOM, nets, '+            'and on each copper layer the pads, traces, vias and the poured copper island by island, plus the drills. Solder mask, paste and '+            'silkscreen are not in it, so a fab order goes through fusion_export_odb (the native ODB++) or the Gerbers.')+    if counts['poursUnfilled']:+        hint += ' %d pour(s) were not filled in this board, so their copper is missing: run fusion_repour and export again.' % counts['poursUnfilled']+    return {'success': True, 'mutated': False, **meta, 'path': path.as_posix(), 'format': 'IPC-2581 revision C', 'mode': 'ASSEMBLY', 'units': 'mm', **counts, '_hint': hint}+++ASCII_SUBS = {'\u03a9': 'ohm', '\u2126': 'ohm', '\u00b5': 'u', '\u03bc': 'u', '\u00b1': '+/-', '\u00b0': 'deg', '\u2013': '-', '\u2014': '-'}+++def ascii_text(t):+    """Fab formats are read by ASCII tools: name the usual unit signs (10k\u03a9 -> 10kohm), '?' for the rest."""+    return ''.join(c if ord(c) < 128 else ASCII_SUBS.get(c, '?') for c in str(t))+++def gencad_text(board, meta):+    """GenCAD 1.4 (mm) from the board: the outline, pads and padstacks, footprint shapes with their+    pins, components, devices, signals with their nodes, and the routes (tracks and vias)."""+    L = Layers(board)+    q = lambda t: '"%s"' % ascii_text(t).replace('"', "'")+    f = lambda v: ('%.4f' % float(v)).rstrip('0').rstrip('.') if '.' in ('%.4f' % float(v)) else '%.4f' % float(v)+    out = ['$HEADER', 'GENCAD 1.4', 'USER "fusion-bridge"', 'DRAWING %s' % q(meta.get('documentName') or 'board'), 'REVISION "1"',+           'UNITS MM', 'ORIGIN 0 0', 'INTERTRACK 0', '$ENDHEADER', '$BOARD']+    model, _ = board_model(board, meta)+    for e in list(model['outlineEdges']) + [x for c in model['outlineCutouts'] for x in c['edges']]:+        if e['kind'] == 'arc':+            out.append('ARC %s %s %s %s %s %s' % (f(e['start'][0]), f(e['start'][1]), f(e['end'][0]), f(e['end'][1]), f(e['center'][0]), f(e['center'][1])))+        else:+            out.append('LINE %s %s %s %s' % (f(e['start'][0]), f(e['start'][1]), f(e['end'][0]), f(e['end'][1])))+    out.append('$ENDBOARD')+    pk = {(l.get('name'), p_.get('name')): p_ for l in board.findall('libraries/library') for p_ in l.findall('packages/package')}+    used = {(e.get('library'), e.get('package')) for e in board.findall('elements/element')}+    pads, stacks, shapes = {}, {}, []+    rv = board.find("designrules/param[@name='rvPadTop']")+    try: rv = float(rv.get('value')) if rv is not None else 0.25+    except ValueError: rv = 0.25+    def pad_of(g):+        if g.tag == 'smd':+            w, h = _f(g, 'dx'), _f(g, 'dy')+            key = ('R', round(w, 4), round(h, 4), 0)+            if key not in pads:+                pads[key] = ('P%d' % (len(pads) + 1), ['PAD %s RECTANGULAR 0' % 'P%d' % (len(pads) + 1), 'RECTANGLE %s %s %s %s' % (f(-w / 2), f(-h / 2), f(w), f(h))])+        else:+            drill = _f(g, 'drill')+            dia = max(_f(g, 'diameter'), drill * (1 + 2 * rv))+            shape = g.get('shape', 'round')+            key = (shape, round(dia, 4), round(drill, 4))+            if key not in pads:+                n = 'P%d' % (len(pads) + 1)+                body = ['RECTANGLE %s %s %s %s' % (f(-dia / 2), f(-dia / 2), f(dia), f(dia))] if shape == 'square' else ['CIRCLE 0 0 %s' % f(dia / 2)]+                pads[key] = (n, ['PAD %s %s %s' % (n, 'RECTANGULAR' if shape == 'square' else ('OCTAGON' if shape == 'octagon' else 'ROUND'), f(drill))] + body)+        return pads[key][0]+    def stack_of(g):+        pn = pad_of(g)+        if g.tag == 'smd':+            layer = 'TOP' if g.get('layer', '1') == '1' else 'BOTTOM'+            key = (pn, layer, 0)+            if key not in stacks:+                sn = 'S%d' % (len(stacks) + 1)+                stacks[key] = (sn, ['PADSTACK %s 0' % sn, 'PAD %s %s 0 0' % (pn, layer)])+        else:+            key = (pn, 'ALL', _f(g, 'drill'))+            if key not in stacks:+                sn = 'S%d' % (len(stacks) + 1)+                stacks[key] = (sn, ['PADSTACK %s %s' % (sn, f(_f(g, 'drill'))), 'PAD %s TOP 0 0' % pn, 'PAD %s BOTTOM 0 0' % pn])+        return stacks[key][0]+    shape_names = {}+    for key in sorted(used, key=lambda k: (k[0] or '', k[1] or '')):+        pkg = pk.get(key)+        if pkg is None:+            continue+        nm = re.sub(r'\s+', '_', '%s' % key[1])+        if nm in shape_names.values():+            nm = '%s_%s' % (nm, re.sub(r'\W+', '_', key[0] or ''))+        shape_names[key] = nm+        body = ['SHAPE %s' % q(nm)]+        th = any(g.tag == 'pad' for g in pkg)+        body.append('INSERT %s' % ('TH' if th else 'SMD'))+        for g in pkg:+            if g.tag in ('pad', 'smd'):+                rot = float((re.search(r'R(-?[\d.]+)', g.get('rot') or 'R0') or [0, 0])[1])+                layer = 'TOP' if g.tag == 'pad' or g.get('layer', '1') == '1' else 'BOTTOM'+                body.append('PIN %s %s %s %s %s %s 0' % (q(g.get('name')), stack_of(g), f(_f(g, 'x')), f(_f(g, 'y')), layer, f(rot)))+        shapes += body+    out += ['$PADS'] + [l for _, (n, lines) in sorted(pads.items(), key=lambda kv: int(kv[1][0][1:])) for l in lines] + ['$ENDPADS']+    out += ['$PADSTACKS'] + [l for _, (n, lines) in sorted(stacks.items(), key=lambda kv: int(kv[1][0][1:])) for l in lines] + ['$ENDPADSTACKS']+    out += ['$SHAPES'] + shapes + ['$ENDSHAPES']+    comps, devs = [], {}+    for el in board.findall('elements/element'):+        key = (el.get('library'), el.get('package'))+        if key not in shape_names:+            continue+        mirrored = 'M' in (el.get('rot') or '')+        rot = float((re.search(r'R(-?[\d.]+)', el.get('rot') or 'R0') or [0, 0])[1])+        dev = '%s_%s' % (shape_names[key], re.sub(r'\W+', '_', el.get('value') or ''))+        devs[dev] = (shape_names[key], el.get('value') or '')+        comps += ['COMPONENT %s' % q(el.get('name')), 'PLACE %s %s' % (f(el.get('x')), f(el.get('y'))), 'LAYER %s' % ('BOTTOM' if mirrored else 'TOP'),+                  'ROTATION %s' % f(rot), 'SHAPE %s %s %s' % (q(shape_names[key]), 'MIRRORY' if mirrored else '0', 'FLIP' if mirrored else '0'), 'DEVICE %s' % q(dev)]+    out += ['$COMPONENTS'] + comps + ['$ENDCOMPONENTS']+    out += ['$DEVICES'] + [l for d, (shp, val) in sorted(devs.items()) for l in ('DEVICE %s' % q(d), 'PART %s' % q(shp), 'VALUE %s' % q(val))] + ['$ENDDEVICES']+    sig = ['$SIGNALS']+    for s_ in board.findall('signals/signal'):+        refs = s_.findall('contactref')+        if not refs:+            continue+        sig.append('SIGNAL %s' % q(s_.get('name')))+        sig += ['NODE %s %s' % (q(c.get('element')), q(c.get('pad'))) for c in refs]+    out += sig + ['$ENDSIGNALS']+    widths = sorted({round(_f(w, 'width'), 4) for s_ in board.findall('signals/signal') for w in s_.findall('wire') if w.get('layer') != '19'})+    tno = {w: i + 1 for i, w in enumerate(widths)}+    out += ['$TRACKS'] + ['TRACK %d %s' % (tno[w], f(w)) for w in widths] + ['$ENDTRACKS']+    via_stacks = {}+    routes = ['$ROUTES']+    for s_ in board.findall('signals/signal'):+        ws = [w for w in s_.findall('wire') if w.get('layer') != '19']+        vs = s_.findall('via')+        if not ws and not vs:+            continue+        routes.append('ROUTE %s' % q(s_.get('name')))+        for w in sorted(ws, key=lambda w: (int(w.get('layer')), _f(w, 'width'))):+            lay = int(w.get('layer'))+            routes += ['TRACK %d' % tno[round(_f(w, 'width'), 4)], 'LAYER %s' % ('TOP' if lay == 1 else ('BOTTOM' if lay == 16 else 'INNER%d' % (L.order.index(lay) if lay in L.order else lay)))]+            if _f(w, 'curve'):+                pts = wire_points((_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2')), _f(w, 'curve'))+                routes += ['LINE %s %s %s %s' % (f(a[0]), f(a[1]), f(b[0]), f(b[1])) for a, b in zip(pts, pts[1:])]+            else:+                routes.append('LINE %s %s %s %s' % (f(_f(w, 'x1')), f(_f(w, 'y1')), f(_f(w, 'x2')), f(_f(w, 'y2'))))+        for v in vs:+            dia = _f(v, 'diameter') or _f(v, 'drill') * 2+            key = (round(dia, 4), round(_f(v, 'drill'), 4))+            if key not in via_stacks:+                vn = 'VIA%d' % (len(via_stacks) + 1)+                pn = 'PV%d' % (len(via_stacks) + 1)+                via_stacks[key] = (vn, ['PAD %s ROUND %s' % (pn, f(key[1])), 'CIRCLE 0 0 %s' % f(dia / 2)], ['PADSTACK %s %s' % (vn, f(key[1])), 'PAD %s TOP 0 0' % pn, 'PAD %s BOTTOM 0 0' % pn])+            routes.append('VIA %s %s %s ALL %s' % (via_stacks[key][0], f(_f(v, 'x')), f(_f(v, 'y')), f(_f(v, 'drill'))))+    routes.append('$ENDROUTES')+    # via pads and padstacks go in their sections+    i_pads, i_stacks = out.index('$ENDPADS'), None+    out[i_pads:i_pads] = [l for _, (vn, pl, sl) in via_stacks.items() for l in pl]+    i_stacks = out.index('$ENDPADSTACKS')+    out[i_stacks:i_stacks] = [l for _, (vn, pl, sl) in via_stacks.items() for l in sl]+    return '\n'.join(out + routes) + '\n', {'components': len(comps) // 6, 'signals': sum(1 for l in sig if l.startswith('SIGNAL')),+                                           'pads': len(pads) + len(via_stacks), 'shapes': len(shape_names)}+++def export_gencad(app, root, board, meta, args):+    text, counts = gencad_text(board, meta)+    stem = re.sub(r'[^A-Za-z0-9_.-]+', '_', meta.get('documentName') or 'board')+    out_dir = Path(args.get('outputDir') or (Path(tempfile.gettempdir()) / 'adom-gencad'))+    out_dir.mkdir(parents=True, exist_ok=True)+    path = out_dir / ('%s.cad' % stem)+    path.write_text(text, encoding='ascii')+    return {'success': True, 'mutated': False, **meta, 'path': path.as_posix(), 'format': 'GenCAD 1.4', 'units': 'mm', **counts,+            '_hint': 'Outline, pads and padstacks, footprint shapes, components (bottom parts mirrored), devices, signals with their nodes, and routes with vias. uniquePins, flipBottomPads and useDrillOrigin are not offered.'}++ def handle(app,args,action):     attempted=False; commands=[]     try:@@ -2140,6 +2700,10 @@ def handle(app,args,action):         if action=='update_board_from_eagle': return update_board_from_eagle(app,root,board,meta,args)         if action=='board_dxf': return board_dxf(app,root,board,meta,args)         if action=='check_inputs': return check_inputs(app,root,board,meta,args)+        if action=='export_odb': return export_odb(app,root,board,meta,args)+        if action=='export_ipc356': return export_ipc356(app,root,board,meta,args)+        if action=='export_gencad': return export_gencad(app,root,board,meta,args)+        if action=='export_ipc2581': return export_ipc2581(app,root,board,meta,args)         if action=='edit_parts': return edit_parts(app,root,board,meta,args)         if action=='align_parts': return align_parts(app,root,board,meta,args)         if action=='remove_graphics': return remove_graphics(app,root,board,meta,args)
rust/lbr/src/lib.rs+97
@@ -948,3 +948,100 @@ mod fusion_att_tests {         assert_eq!(md.totals.with_mpn, 1);     } }++/// The standard's read_footprint: every detail of one package, from a .lbr or from a library a+/// board embeds (`library` names it; a .lbr has one). Pads with their number, type, shape,+/// position, size, drill and layers; the layers used; the courtyard box (EAGLE's component+/// exclude layers 39/40, else the pads with the placement outline); graphics per layer; the+/// description; and the 3D packages that instance it.+pub fn read_footprint(xml: &str, library: Option<&str>, name: &str) -> Result<serde_json::Value, LbrError> {+    use serde_json::json;+    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+    let el = |n: &roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;+    let layer_names: BTreeMap<String, String> = doc.root_element().descendants().filter(|n| el(n, "layer"))+        .map(|l| (l.attribute("number").unwrap_or("").to_string(), l.attribute("name").unwrap_or("").to_string())).collect();+    let lname = |n: &str| layer_names.get(n).cloned().unwrap_or_else(|| n.to_string());+    let libs: Vec<roxmltree::Node> = doc.root_element().descendants().filter(|n| el(n, "library"))+        .filter(|l| library.map(|want| l.attribute("name").map(|n| n.eq_ignore_ascii_case(want)).unwrap_or(true)).unwrap_or(true)).collect();+    if libs.is_empty() { return Err(LbrError::NotALibrary(format!("no library {}", library.unwrap_or("")))); }+    let pkgs: Vec<roxmltree::Node> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package") && n.parent().map(|p| el(&p, "packages")).unwrap_or(false)).collect::<Vec<_>>()).collect();+    let Some(pkg) = pkgs.iter().find(|p| p.attribute("name") == Some(name)).or_else(|| pkgs.iter().find(|p| p.attribute("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false))) else {+        let names: Vec<&str> = pkgs.iter().filter_map(|p| p.attribute("name")).collect();+        return Err(LbrError::NotALibrary(format!("no footprint {name}; the library has {}", names.join(", "))));+    };+    let f = |n: &roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+    let rot = |n: &roxmltree::Node| n.attribute("rot").unwrap_or("R0").trim_start_matches('S').trim_start_matches('M').trim_start_matches('R').parse::<f64>().unwrap_or(0.0);+    let mut pads = Vec::new();+    let mut used: std::collections::BTreeSet<String> = Default::default();+    let mut graphics: BTreeMap<String, usize> = BTreeMap::new();+    let (mut bx, mut cy) = (Vec::<(f64, f64)>::new(), Vec::<(f64, f64)>::new());+    for g in pkg.children().filter(|c| c.is_element()) {+        match g.tag_name().name() {+            "smd" => {+                let layer = g.attribute("layer").unwrap_or("1");+                let mut layers: Vec<String> = vec![lname(layer)];+                if g.attribute("stop") != Some("no") { layers.push(lname(if layer == "16" { "30" } else { "29" })); }+                if g.attribute("cream") != Some("no") { layers.push(lname(if layer == "16" { "32" } else { "31" })); }+                let (x, y, dx, dy) = (f(&g, "x"), f(&g, "y"), f(&g, "dx"), f(&g, "dy"));+                let rr = f(&g, "roundness");+                bx.extend([(x - dx / 2.0, y - dy / 2.0), (x + dx / 2.0, y + dy / 2.0)]);+                used.extend(layers.iter().cloned());+                pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "smd", "shape": if rr >= 100.0 { "round" } else if rr > 0.0 { "roundrect" } else { "rect" },+                                 "at": [x, y], "sizeMm": [dx, dy], "drillMm": null, "layers": layers, "rotation": rot(&g), "roundness": rr}));+            }+            "pad" => {+                let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));+                let dia = if f(&g, "diameter") > 0.0 { f(&g, "diameter") } else { d * 1.6 };+                let shape = g.attribute("shape").unwrap_or("round");+                let size = if shape == "long" || shape == "offset" { [dia * 2.0, dia] } else { [dia, dia] };+                bx.extend([(x - size[0] / 2.0, y - size[1] / 2.0), (x + size[0] / 2.0, y + size[1] / 2.0)]);+                let mut layers = vec![lname("1"), lname("16"), lname("17")];+                if g.attribute("stop") != Some("no") { layers.extend([lname("29"), lname("30")]); }+                used.extend(layers.iter().cloned());+                pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "thru", "shape": shape, "at": [x, y], "sizeMm": size, "drillMm": d,+                                 "layers": layers, "rotation": rot(&g), "plated": true}));+            }+            "hole" => {+                let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));+                used.insert(lname("45"));+                pads.push(json!({"number": "", "type": "npth", "shape": "round", "at": [x, y], "sizeMm": [d, d], "drillMm": d, "layers": [lname("45")], "rotation": 0.0, "plated": false}));+            }+            "description" => {}+            tag => {+                if let Some(l) = g.attribute("layer") {+                    *graphics.entry(lname(l)).or_default() += 1;+                    used.insert(lname(l));+                    let mut pts: Vec<(f64, f64)> = match tag {+                        "wire" | "rectangle" => vec![(f(&g, "x1"), f(&g, "y1")), (f(&g, "x2"), f(&g, "y2"))],+                        "circle" => { let (x, y, r) = (f(&g, "x"), f(&g, "y"), f(&g, "radius")); vec![(x - r, y - r), (x + r, y + r)] }+                        _ => g.descendants().filter(|v| el(v, "vertex")).map(|v| (f(&v, "x"), f(&v, "y"))).collect(),+                    };+                    if l == "39" || l == "40" { cy.append(&mut pts); } else if l == "21" || l == "22" || l == "51" || l == "52" { bx.append(&mut pts); }+                }+            }+        }+    }+    let boxed = |p: &[(f64, f64)]| -> serde_json::Value {+        if p.is_empty() { return serde_json::Value::Null; }+        let r = |v: f64| (v * 10000.0).round() / 10000.0;+        json!([r(p.iter().map(|q| q.0).fold(f64::MAX, f64::min)), r(p.iter().map(|q| q.1).fold(f64::MAX, f64::min)),+               r(p.iter().map(|q| q.0).fold(f64::MIN, f64::max)), r(p.iter().map(|q| q.1).fold(f64::MIN, f64::max))])+    };+    let (courtyard, measured) = if !cy.is_empty() { (boxed(&cy), "component exclude layer (39/40)") } else { (boxed(&bx), "pads and placement outline (the package has no exclude layer)") };+    let lib_name = pkg.ancestors().find(|a| el(a, "library")).and_then(|l| l.attribute("name")).unwrap_or("");+    let models: Vec<serde_json::Value> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package3d")).collect::<Vec<_>>())+        .filter(|p3| p3.descendants().any(|i| el(&i, "packageinstance") && i.attribute("name") == pkg.attribute("name")))+        .map(|p3| json!({"name": p3.attribute("name").unwrap_or(""), "urn": p3.attribute("urn").filter(|u| !u.is_empty()).or(p3.attribute("wip_urn")).unwrap_or(""),+                         "type": p3.attribute("type").unwrap_or(""), "path": null, "offset": null, "scale": null, "rotate": null})).collect();+    let smd = pads.iter().filter(|p| p["type"] == "smd").count();+    let tht = pads.iter().filter(|p| p["type"] == "thru").count();+    Ok(json!({+        "name": pkg.attribute("name").unwrap_or(""), "library": lib_name,+        "description": pkg.children().find(|c| el(c, "description")).and_then(|d| d.text()).unwrap_or("").trim(),+        "pads": pads, "layers": used.into_iter().collect::<Vec<_>>(), "courtyardMm": courtyard, "courtyardFrom": measured, "graphics": graphics,+        "attributes": {"mount": if tht > 0 && smd > 0 { "mixed" } else if tht > 0 { "tht" } else if smd > 0 { "smd" } else { "none" }, "excludeFromBom": null, "excludeFromPos": null, "dnp": null},+        "properties": {"locallyModified": pkg.attribute("locally_modified").unwrap_or("no")},+        "models": models,+    }))+}
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.1.1"+version = "0.1.2" edition = "2021" description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
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.4.2-- Standard commit: 680ab693fc0d4b1ec948b85ae4a3c28c51a6a961-- Crate version: 0.1.1+- Standard version: 0.4.3+- Standard commit: 36e8072e0b6aa524169534eb43fda219f51b7a71+- Crate version: 0.1.2 - Copied: 2026-10-27, 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/fab.rs+32−3
@@ -118,14 +118,32 @@ pub fn compare(meas: &Map<String, Value>, limits: &Map<String, Value>, clearance     out } +/// A length written with its unit ("0.1mm", "4 mil", "0.004in", "100um") in mm; None without a known unit.+fn length_mm(text: &str) -> Option<f64> {+    let t = text.trim();+    let num: String = t.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+    let v: f64 = num.parse().ok()?;+    match t[num.len()..].trim().to_ascii_lowercase().as_str() {+        "mm" => Some(v),+        "mil" | "mils" | "th" => Some(v * 0.0254),+        "in" | "inch" => Some(v * 25.4),+        "um" | "\u{b5}m" => Some(v / 1000.0),+        _ => None,+    }+}+ /// The smallest clearance among the board's enabled clearance rules, in mm. A rule gives it as a number-/// (`clearanceMm`, `gapMm` or `minMm`) or in a descriptor the tool wrote ("Gap=10mil", "Gap=0.2mm": Altium's form).+/// (`clearanceMm`, `gapMm` or `minMm`), as a `value` with its unit ("0.1mm": Fusion's form), or in a descriptor the tool+/// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Same-net spacing rules are not clearance between nets: left out. pub fn min_clearance(rules: &[Value]) -> Option<f64> {-    rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("clearance")).unwrap_or(false)-            || r["kindName"].as_str() == Some("Clearance") || r["name"].as_str().map(|n| n.to_lowercase().contains("clearance")).unwrap_or(false))+    let text = |r: &Value, k: &str| r[k].as_str().map(|s| s.to_lowercase()).unwrap_or_default();+    rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || text(r, "kind").contains("clearance")+            || r["kindName"].as_str() == Some("Clearance") || text(r, "name").contains("clearance"))+        .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); t.contains("same net") || t.contains("samenet") || t.contains("same-net") }))         .filter(|r| r["enabled"].as_bool() != Some(false))         .filter_map(|r| {             if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }+            if let Some(v) = r["value"].as_str().and_then(length_mm) { return Some(v); }             let d = r["descriptor"].as_str()?;             let i = d.find("Gap=")? + 4;             let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();@@ -291,4 +309,15 @@ mod tests {     fn clearance_from_a_standard_rule() {         assert_eq!(min_clearance(&[json!({"kind": "clearance", "clearanceMm": 0.127}), json!({"kind": "clearance", "gapMm": 0.2})]), Some(0.127));     }++    #[test]+    fn clearance_from_a_value_with_its_unit_and_never_same_net() {+        // Fusion writes rules as a kind name and a value with its unit+        let rules = vec![json!({"kind": "Copper Clearance", "value": "0.15mm"}), json!({"kind": "Copper Clearance", "value": "5mil"}),+                         json!({"kind": "Same Net Clearance", "value": "0.05mm"}), json!({"kind": "Copper Clearance", "value": "0.2"})];+        assert_eq!(min_clearance(&rules), Some(0.127));+        assert_eq!(length_mm("0.004in"), Some(0.1016));+        assert_eq!(length_mm("100um"), Some(0.1));+        assert_eq!(length_mm("0.2"), None, "a bare number has no unit: never guessed");+    } }
tests/run_verb_tests.py+8−2
@@ -242,6 +242,12 @@ t("fusion_return_path", "daily", B, {"ok": True, "fields": ["engine"]}) t("fusion_impedance", "daily", dict(B, z0=50), {"ok": True, "fields": ["layers"]}) t("fusion_current_capacity", "daily", dict(B, nets={"VBUS": 3}), {"ok": True, "fields": ["nets"]}) t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": ["measured", "outsideLimits", "fab"]}, "fetches JLCPCB's record from adom/pcb-design-rules")+t("fusion_export_odb", "daily", dict(B, outputDir=f"{SCRATCH}/odb"), {"ok": True, "fields": ["path", "layers", "nets"]}, "Fusion's own ODB++ tree, found by its matrix and zipped")+t("fusion_export_ipc356", "daily", dict(B, outputDir=f"{SCRATCH}/ipc356"), {"ok": True, "fields": ["path", "records"]})+t("fusion_export_gencad", "daily", dict(B, outputDir=f"{SCRATCH}/gencad"), {"ok": True, "fields": ["path", "components", "signals"]})+t("fusion_export_ipc2581", "daily", dict(B, outputDir=f"{SCRATCH}/ipc2581"), {"ok": True, "fields": ["path", "components", "pourIslands"]})+t("fusion_read_footprint", "daily", dict(B, libId="Capacitor:CAPC2012X110"), {"ok": True, "fields": ["pads", "courtyardMm", "layers"]})+t("fusion_read_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "name": "X"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") t("fusion_export_bom#parts", "daily", {"outputPath": f"{SCRATCH}/bom-parts.csv"}, {"ok": True, "pred": "bom_has_part_numbers"}, "1.12.0: MPN/LCSC columns from the part attributes (page issue #105)") t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["commands"]}, "TextCommands.List needs the electronics editor context") t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)")@@ -433,8 +439,8 @@ _SECTION_BY_VERB = {     "fusion_action_ledger": "bridge-lifecycle", "fusion_addin_status": "python-addin", "fusion_alerts": "gui-driving",     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",-    "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+    "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",+    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tests/test_routing.py+55
@@ -347,4 +347,59 @@ class CheckInputsTests(unittest.TestCase):         self.assertEqual(0.2, r._mm_value('0.2mm'))  +class Ipc356Tests(unittest.TestCase):+    def test_fixed_columns(self):+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        m, _ = r.board_model(b, {})+        m['nets']; lines = r.ipc356_lines(m, 'fixture')+        self.assertEqual('999', lines[-1])+        self.assertIn('P  UNITS CUST 1', lines)+        recs = [l for l in lines if l[:3] in ('317', '327')]+        self.assertEqual(len(m['pads']) + len(m['vias']), len(recs))+        for l in recs:+            self.assertEqual('-', l[26]); self.assertEqual('X', l[41]); self.assertEqual('Y', l[49]); self.assertEqual('R', l[67])+        long_ = dict(m, pads=[dict(m['pads'][0], net='A_VERY_LONG_NET_NAME_1')])+        out = r.ipc356_lines(long_, 'x')+        self.assertIn('P  NNAME1 A_VERY_LONG_NET_NAME_1', out)++++class GencadTests(unittest.TestCase):+    def test_sections_and_references(self):+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        text, counts = r.gencad_text(b, {'documentName': 'fixture'})+        L = text.splitlines()+        def sec(n): return L[L.index('$' + n) + 1:L.index('$END' + n)]+        for n in ('HEADER', 'BOARD', 'PADS', 'PADSTACKS', 'SHAPES', 'COMPONENTS', 'DEVICES', 'SIGNALS', 'TRACKS', 'ROUTES'):+            sec(n)+        pads = {l.split()[1] for l in sec('PADS') if l.startswith('PAD ')}+        stacks = {l.split()[1] for l in sec('PADSTACKS') if l.startswith('PADSTACK ')}+        shapes = {l.split(None, 1)[1] for l in sec('SHAPES') if l.startswith('SHAPE ')}+        self.assertTrue(all(l.split()[1] in pads for l in sec('PADSTACKS') if l.startswith('PAD ')))+        self.assertTrue(all(l.split()[2] in stacks for l in sec('SHAPES') if l.startswith('PIN ')))+        self.assertTrue(all(l.split()[1] in stacks for l in sec('ROUTES') if l.startswith('VIA ')))+        self.assertTrue(all(l.split(None, 1)[1].rsplit(' ', 2)[0] in shapes for l in sec('COMPONENTS') if l.startswith('SHAPE ')))+        self.assertEqual(len(b.findall('elements/element')), counts['components'])+        self.assertGreaterEqual(len(sec('BOARD')), 4)++++class Ipc2581Tests(unittest.TestCase):+    def test_valid_against_the_c_schema(self):+        try:+            from lxml import etree+        except ImportError:+            self.skipTest('lxml not installed')+        schema = etree.XMLSchema(etree.parse(str(ROOT/'tests/fixtures/IPC-2581C.xsd')))+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        text, counts = r.ipc2581_xml(b, {'documentName': 'fixture'}, version='test')+        doc = etree.fromstring(text.encode())+        self.assertTrue(schema.validate(doc), [e.message for e in schema.error_log][:5])+        self.assertEqual(len(b.findall('elements/element')), counts['components'])+        self.assertEqual(['ASSEMBLY'], [e.get('mode') for e in doc.iter('{http://webstds.ipc.org/2581}FunctionMode')])++    def test_ascii_text(self):+        self.assertEqual('10kohm +/-1% 4.7uF', r.ascii_text('10k\u03a9 \u00b11% 4.7\u00b5F'))++ if __name__=='__main__':unittest.main()