app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 17 file(s)
17 files changed
+418−33
BRIDGE_VERSION+1−1CHANGELOG.md+11bridge.json+9−1contracts/describe.json+97dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+16install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+5−5rust/Cargo.toml+1−1rust/bridge/src/boardgen.rs+88rust/bridge/src/main.rs+17−2rust/bridge/src/unsupported.rs+57rust/lbr/src/authoring.rs+102−8tests/run_verb_tests.py+5BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.14+1.13.15
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.15 (2026-10-29)++- `fusion_not_supported`: the standard verbs that belong to Altium's own machinery (Altium 365, its script agent and+ .NET extension, integrated libraries, OutJobs, symbol display modes, library defaults) answer not_supported with what+ Fusion does instead, under their own names (fusion_a365_status ...) or through this verb, which lists them.+- `fusion_swap_footprint` (the standard's swap_footprint): a part on a board kept as EAGLE source takes another+ footprint at the same place and turn, its pads rewired from assigns by pad number; designator picks the part.+- `fusion_set_models` and `fusion_add_models` (the standard's): a component keeps one footprint, or gains more as+ further devices, pins joined to pads by number (repeated numbers' N@2 pads too); a footprint already attached is+ skipped. set_params joins a footprint the same way. 334 verbs.+ ## 1.13.14 (2026-10-29) - Engine 0.18.0 (standard 0.21.0). plan_route and the other planners take the track width from the board's minimum
bridge.json+9−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.14",+ "version": "1.13.15", "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.",@@ -24,6 +24,7 @@ "fusion_account", "fusion_action_ledger", "fusion_activate_document",+ "fusion_add_models", "fusion_add_rule", "fusion_addin_status", "fusion_alerts",@@ -207,6 +208,7 @@ "fusion_net_lengths", "fusion_netlist", "fusion_new_electronics_from_eagle",+ "fusion_not_supported", "fusion_notify_owner", "fusion_open_board", "fusion_open_by_urn",@@ -311,6 +313,7 @@ "fusion_set_driving_badge", "fusion_set_layer_count", "fusion_set_layer_visibility",+ "fusion_set_models", "fusion_set_outline", "fusion_set_parameter", "fusion_set_params",@@ -339,6 +342,7 @@ "fusion_state", "fusion_status", "fusion_stop",+ "fusion_swap_footprint", "fusion_take_screenshot", "fusion_test_board", "fusion_thermal_vias",@@ -510,6 +514,10 @@ "project_snapshot": 300, "project_create": 180, "test_board": 180,+ "swap_footprint": 180,+ "set_models": 30,+ "add_models": 30,+ "not_supported": 10, "import_part": 600, "footprint_from_land_pattern": 30, "build_board": 180,
contracts/describe.json+97@@ -55,6 +55,39 @@ "summary": "Make a named open document the ACTIVE one before driving it. EDIT and most modelling verbs act on whatever is active, so in a batch with several documents open this is how you stop a verb landing in the wrong file (page issue #47). Prefer passing expectDocument on the verb itself, which fails closed instead of merely re-targeting. An electronics design's schematic, board and 3D view are separate tabs with ONE name (type unknown): the reply says so in sameNameDocuments, and fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board {documentName} reach a specific view.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "libraryPath": "C:/libs/parts.lbr",+ "models": [+ {+ "name": "SOT-23-5"+ }+ ],+ "symbolName": "LDO"+ },+ "input": {+ "comment": "str",+ "description": "str",+ "dryRun": "bool",+ "libraryPath": ".lbr on the Fusion machine (required)",+ "models": "[{name, pcblib?}] (required)",+ "params": "{key: value}",+ "symbolName": "the component (required)"+ },+ "longRunning": false,+ "name": "fusion_add_models",+ "output": {+ "added": "[str]",+ "alreadyAttached": "[str]",+ "libref": "str",+ "models": "int (footprints on the component now)",+ "params": "int"+ },+ "statusVerb": null,+ "summary": "Attach more footprints to a component (the standard's add_models): each becomes another device of the EAGLE component (Fusion's several-packages-per-component), pins joined by pad number; a model's footprint in another .lbr (pcblib) is copied in first. A footprint already attached is skipped (alreadyAttached), so it is safe to repeat. params, comment and description go on the component. Offline, preflighted.",+ "timeoutSeconds": 30+ }, { "example": { "gap": 0.3,@@ -17909,6 +17942,20 @@ "summary": "Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + populated board, lands in the PCB editor). The ONLY way to author a board from EAGLE source: Document.newDesignFromLocal opens the schematic editor but does NOT instantiate parts, and the Fusion-native .fsch/.fbrd container is opaque binary you can't build offline. This fires Fusion's own ImportSCHAndBRDCmd and drives BOTH native Open dialogs (the .sch then the .brd) in the BACKGROUND via desktop_ui_click by accessible name (no foreground). Stage both files to Windows first (send_files). After: fusion_show_2d_board, RATSNEST + 'AUTO ;' to autoroute, fusion_show_3d_board for 3D. PREFLIGHT (1.10.20): refuses with eagle_files_missing before touching any dialog when either file is not on the Fusion machine; if the Open dialog does not accept the file the bridge cancels the dialog itself (open_dialog_stuck), so nothing is ever left on the user's screen. Prefer fusion_schematic {action:\"open\"}, which picks this route only when a .brd exists next to the .sch. ASYNC by contract (1.10.22): returns {started:true, runId, statusVerb} at once and runs the dialog-driven import on the exe; poll fusion_eagle_import_status {runId}. The record and the final reply carry documentName, partsExpected (elements in the .brd) and partsPlaced (read back from the board); success needs parts on the board (nothing_instantiated otherwise). Pass wait:true only from inside the exe.", "timeoutSeconds": 200 },+ {+ "example": {},+ "input": {+ "verb": "one of them (optional): its refusal"+ },+ "longRunning": false,+ "name": "fusion_not_supported",+ "output": {+ "verbs": "[{verb, why}]"+ },+ "statusVerb": null,+ "summary": "The standard verbs that belong to Altium's own machinery (the Altium 365 cloud and workspace, the DelphiScript agent, the .NET extension, integrated libraries, OutJobs, symbol display modes, library defaults) and what Fusion does instead. Calling any of them, fusion_<verb> or the bare name, answers not_supported through this verb; with no verb it lists them.",+ "timeoutSeconds": 10+ }, { "example": { "body": "Please complete the Autodesk 2FA prompt on winvm - everything else is done.",@@ -20322,6 +20369,30 @@ "summary": "Show or hide one layer of the live 2D board (DISPLAY), read back from the board's own layer flags. Layer by id (fusion_layers), copper name (F.Cu, In1.Cu, B.Cu) or top/bottom. Nothing on the board changes.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "footprint": "SOT-23-5",+ "libraryPath": "C:/libs/parts.lbr",+ "symbolName": "LDO"+ },+ "input": {+ "dryRun": "bool",+ "footprint": "a footprint in the library (required)",+ "libraryPath": ".lbr on the Fusion machine (required)",+ "symbolName": "the component (required)"+ },+ "longRunning": false,+ "name": "fusion_set_models",+ "output": {+ "component": "str",+ "footprint": "str",+ "removed": "int"+ },+ "statusVerb": null,+ "summary": "The component keeps ONE footprint (the standard's set_models): every other device of the EAGLE component is removed and the footprint's device joins each pin to its pads by number (a repeated number's N@2 pads too). Offline on a .lbr, preflighted; the attributes stay on the kept device.",+ "timeoutSeconds": 30+ }, { "example": { "outline": {@@ -20936,6 +21007,32 @@ "summary": "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait up to closeTimeoutSec (default 120; heavy designs take minutes to close). NO force-kill - the clean opposite of fusion_start. A save prompt for a document the bridge made is answered Don't Save; one for anyone else's unsaved work is cancelled and the stop fails with errorCode unsaved_work (nothing saved or lost). If a window will not close it returns failure; use fusion_kill then.", "timeoutSeconds": 300 },+ {+ "example": {+ "assigns": "1=GND;2=VDD",+ "footprintName": "VL53L5CX",+ "libraryPath": "C:/libs/sensors.lbr",+ "pcbPath": "C:/Boards/tof-test.brd"+ },+ "input": {+ "assigns": "pad=NET;...",+ "designator": "the part (default: the first)",+ "footprintName": "str (required)",+ "libraryPath": ".lbr holding the footprint (required)",+ "open": "bool, default true",+ "pcbPath": ".brd (required)"+ },+ "longRunning": false,+ "name": "fusion_swap_footprint",+ "output": {+ "data": "{placed, padsOnComp, bodiesOnComp}",+ "documentName": "str",+ "swap": "{designator, from, to, padsWired, unknownPads, has3d}"+ },+ "statusVerb": null,+ "summary": "A part on a board kept as EAGLE source (fusion_test_board's .brd) takes another footprint from a .lbr at the same place and turn (the standard's swap_footprint); its pads leave their nets and take assigns (pad number -> net, every pad of that number); tracks stay. designator picks the part (default: the first). The board reopens in Fusion as a local design and is read back.",+ "timeoutSeconds": 180+ }, { "example": {}, "input": {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.14"+version = "1.13.15" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.14"+version = "1.13.15" edition = "2021" [dependencies]
describe.py+16@@ -394,6 +394,22 @@ _T = [ {"libraryPath": ".lbr on the Fusion machine (pcbLib/schLib accepted: one Fusion library holds both)", "footprintSpec": "path or text: META|name|desc PAD|des|x|y|w|h|shape|hole|layer|crpct LINE ARC DISC (mm)", "symbolSpec": "path or text: META|libref|desc|designator MODE RECT PIN|mode|number|name|x|y|orient|len|hide|etype LINE ARC (mils; x,y the pin's body end)", "step": ".step path (needs a symbol; signed-in Fusion)", "footprint": "an existing footprint for the symbol when no footprintSpec", "params": "{MPN: ...}", "comment": "str", "description": "str", "mode": "symbol display mode, default 0", "dryRun": "bool"}, {"footprint": "str", "symbol": "str", "steps": "[{build_footprint, padAliases, replaced}, {build_symbol, modesInSpec}, {add_models}, {params}, {add_body, wipUrn}]", "backup": "path"}, 600, None, False, {"libraryPath": "C:/libs/parts.lbr", "footprintSpec": "C:/specs/fp.spec", "symbolSpec": "C:/specs/sym.spec", "params": {"MPN": "TPS7A2033"}, "dryRun": True}),+ ('fusion_not_supported', "The standard verbs that belong to Altium's own machinery (the Altium 365 cloud and workspace, the DelphiScript agent, the .NET extension, integrated libraries, OutJobs, symbol display modes, library defaults) and what Fusion does instead. Calling any of them, fusion_<verb> or the bare name, answers not_supported through this verb; with no verb it lists them.",+ {"verb": "one of them (optional): its refusal"},+ {"verbs": "[{verb, why}]"},+ 10, None, False, {}),+ ('fusion_set_models', "The component keeps ONE footprint (the standard's set_models): every other device of the EAGLE component is removed and the footprint's device joins each pin to its pads by number (a repeated number's N@2 pads too). Offline on a .lbr, preflighted; the attributes stay on the kept device.",+ {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)", "footprint": "a footprint in the library (required)", "dryRun": "bool"},+ {"component": "str", "footprint": "str", "removed": "int"},+ 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO", "footprint": "SOT-23-5", "dryRun": True}),+ ('fusion_add_models', "Attach more footprints to a component (the standard's add_models): each becomes another device of the EAGLE component (Fusion's several-packages-per-component), pins joined by pad number; a model's footprint in another .lbr (pcblib) is copied in first. A footprint already attached is skipped (alreadyAttached), so it is safe to repeat. params, comment and description go on the component. Offline, preflighted.",+ {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)", "models": "[{name, pcblib?}] (required)", "params": "{key: value}", "comment": "str", "description": "str", "dryRun": "bool"},+ {"libref": "str", "models": "int (footprints on the component now)", "added": "[str]", "alreadyAttached": "[str]", "params": "int"},+ 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO", "models": [{"name": "SOT-23-5"}], "dryRun": True}),+ ('fusion_swap_footprint', "A part on a board kept as EAGLE source (fusion_test_board's .brd) takes another footprint from a .lbr at the same place and turn (the standard's swap_footprint); its pads leave their nets and take assigns (pad number -> net, every pad of that number); tracks stay. designator picks the part (default: the first). The board reopens in Fusion as a local design and is read back.",+ {"libraryPath": ".lbr holding the footprint (required)", "footprintName": "str (required)", "pcbPath": ".brd (required)", "assigns": "pad=NET;...", "designator": "the part (default: the first)", "open": "bool, default true"},+ {"swap": "{designator, from, to, padsWired, unknownPads, has3d}", "data": "{placed, padsOnComp, bodiesOnComp}", "documentName": "str"},+ 180, None, False, {"libraryPath": "C:/libs/sensors.lbr", "footprintName": "VL53L5CX", "pcbPath": "C:/Boards/tof-test.brd", "assigns": "1=GND;2=VDD"}), ('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.14"+DASH_VERSION="1.13.15" 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.14",+ "version": "1.13.15", "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+5−11@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.14",+ "version": "1.13.15", "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": {@@ -17,9 +17,7 @@ "pcb", "exports", "aps",- "modeling",- "mechanical",- "assemblies"+ "modeling" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",@@ -92,13 +90,9 @@ "pin electrical types from a schematic", "part heights on my fusion board", "which fusion parts have no 3d model",- "will my fusion board fit the enclosure",- "build a parametric part in fusion",- "assemble mechanical parts in fusion",- "insert a McMaster part in fusion",- "generate a mechanical assembly BOM"+ "will my fusion board fit the enclosure" ],- "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",+ "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.", "sample_prompts": [ {@@ -149,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
rust/Cargo.lock+5−5@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.14"+version = "1.13.15" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.14"+version = "1.13.15" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.14"+version = "1.13.15" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.14"+version = "1.13.15" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.14"+version = "1.13.15" 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.14"+version = "1.13.15" edition = "2021" license = "MIT"
rust/bridge/src/boardgen.rs+88@@ -227,6 +227,83 @@ pub fn compose(p: &Plan) -> Result<(String, Value), String> { Ok((root.to_xml_document(), json!({"unknownPins": unknown, "conflicts": conflicts, "packagesAdded": added, "outlineGrown": grown, "outsideOutline": outside}))) } +/// The standard's swap_footprint on board text: the part (`des`, else the first) takes `pkg` from the+/// .lbr at the same place and turn; its pads leave their nets and take `assigns` (pad number -> net,+/// every pad of that number: N and its repeats N@2 ...). Tracks stay.+pub fn swap(text: &str, lbr: &str, lib_name: &str, des: Option<&str>, pkg: &str, assigns: &[(String, String)]) -> Result<(String, Value), String> {+ let lib_pkgs = lbr_packages(lbr)?;+ let src = lib_pkgs.iter().find(|k| k.attr("name").map(|n| n.eq_ignore_ascii_case(pkg)).unwrap_or(false)).ok_or(format!("footprint {pkg} is not in the library"))?.clone();+ let name = src.attr("name").unwrap_or(pkg).to_string();+ let lib_root = Element::parse(lbr).map_err(|e| e.to_string())?;+ let urn = deep(&lib_root, "packages3d").and_then(|s| s.children.iter().find(|d| d.find_child("packageinstances").map(|pi| pi.children.iter().any(|c| c.attr("name") == Some(name.as_str()))).unwrap_or(false)))+ .and_then(|d| d.attr("wip_urn").or(d.attr("urn")).map(str::to_string)).filter(|u| !u.is_empty());+ let mut root = Element::parse(text).map_err(|e| e.to_string())?;+ let board = root.find_deep_mut("board").ok_or("not a board: no <board>")?;+ let r = {+ let els = board.find_child_mut("elements").ok_or("the board has no parts")?;+ let e = match des { Some(d) => els.children.iter_mut().find(|c| c.tag == "element" && c.attr("name").map(|n| n.eq_ignore_ascii_case(d)).unwrap_or(false)).ok_or(format!("no part {d} on the board"))?,+ None => els.children.iter_mut().find(|c| c.tag == "element").ok_or("the board has no parts")? };+ let old = e.attr("package").unwrap_or("").to_string();+ for (k, v) in [("library", lib_name.to_string()), ("package", name.clone())] { match e.attrs.iter_mut().find(|a| a.0 == k) { Some(a) => a.1 = v, None => e.attrs.push((k.into(), v)) } }+ e.attrs.retain(|a| a.0 != "package3d_urn");+ if let Some(u) = &urn { e.attrs.push(("package3d_urn".into(), u.clone())); }+ (e.attr("name").unwrap_or("").to_string(), old)+ };+ {+ let libs = board.find_child_mut("libraries").ok_or("the board has no libraries")?;+ if child_named(libs, "library", lib_name).is_none() { let mut l = el("library", &[("name", lib_name.to_string())]); l.children.push(Element::new("packages")); libs.children.push(l); }+ let lib = libs.children.iter_mut().find(|c| c.tag == "library" && c.attr("name").map(|n| n.eq_ignore_ascii_case(lib_name)).unwrap_or(false)).unwrap();+ if lib.find_child("packages").is_none() { lib.children.insert(0, Element::new("packages")); }+ let pk = lib.find_child_mut("packages").unwrap();+ // a footprint of the same name already there is replaced: the swap brings the library's version+ pk.children.retain(|c| !(c.tag == "package" && c.attr("name").map(|n| n.eq_ignore_ascii_case(&name)).unwrap_or(false)));+ pk.children.push(src.clone());+ }+ let pads: Vec<String> = pads_of(&src).into_iter().map(|p| p.0).collect();+ let base = |p: &str| p.split('@').next().unwrap_or(p).to_string();+ let mut wired = 0; let mut unknown = Vec::new();+ {+ let signals = match board.find_child_mut("signals") { Some(s) => s, None => { board.children.push(Element::new("signals")); board.find_child_mut("signals").unwrap() } };+ for s in signals.children.iter_mut().filter(|c| c.tag == "signal") { s.children.retain(|c| !(c.tag == "contactref" && c.attr("element").map(|n| n.eq_ignore_ascii_case(&r.0)).unwrap_or(false))); }+ for (num, net) in assigns {+ let mine: Vec<&String> = pads.iter().filter(|p| base(p) == *num || *p == num).collect();+ if mine.is_empty() { unknown.push(json!({"pad": num, "net": net})); continue; }+ if !signals.children.iter().any(|c| c.tag == "signal" && c.attr("name") == Some(net.as_str())) { signals.children.push(el("signal", &[("name", net.clone())])); }+ let sg = signals.children.iter_mut().find(|c| c.tag == "signal" && c.attr("name") == Some(net.as_str())).unwrap();+ for p in mine { sg.children.push(el("contactref", &[("element", r.0.clone()), ("pad", p.clone())])); wired += 1; }+ }+ }+ Ok((root.to_xml_document(), json!({"designator": r.0, "from": r.1, "to": name, "padsWired": wired, "unknownPads": unknown, "has3d": urn.is_some()})))+}++/// `fusion_swap_footprint`: a part on a board kept as EAGLE source takes another footprint at the+/// same place; nets come from assigns; the board reopens in Fusion and is read back.+pub fn swap_footprint(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (lbr_path, lbr) = match read_lbr(args) { Ok(x) => x, Err(e) => return e };+ let Some(pcb) = args["pcbPath"].as_str().filter(|s| s.to_lowercase().ends_with(".brd")).map(|s| s.replace('\\', "/")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "pcbPath (an EAGLE .brd, as fusion_test_board writes) is required",+ "_hint": "Fusion swaps a footprint on a board kept as EAGLE source. On an open cloud design, a part takes another of its component's footprints in Fusion's PACKAGE command, or another component with fusion_sch_swap_part."}) };+ let Some(fp) = args["footprintName"].as_str().filter(|s| !s.is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "footprintName is required"}) };+ let text = match std::fs::read_to_string(&pcb) { Ok(t) => t, Err(e) => return json!({"success": false, "errorCode": "board_unreadable", "error": format!("{pcb}: {e}")}) };+ let assigns: Vec<(String, String)> = list(&args["assigns"], ';').iter().filter_map(|a| a.split_once('=').map(|(p, n)| (p.trim().to_string(), n.trim().to_string()))).collect();+ let (xml, rep) = match swap(&text, &lbr, &lib_name_of(&lbr_path), args["designator"].as_str(), fp, &assigns) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "swap_refused", "error": e}) };+ if let Err(e) = write(&pcb, &xml) { return e; }+ let des = rep["designator"].as_str().unwrap_or("").to_string();+ let mut out = json!({"success": true, "mutated": true, "pcb": pcb, "swap": rep});+ if args["open"].as_bool() == Some(false) { return out; }+ match open_and_read(ctx, &pcb, pass) {+ Err(e) => e,+ Ok((doc, st)) => {+ let lc = st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(&des)).unwrap_or(false)).cloned());+ out["documentName"] = json!(doc);+ out["data"] = json!({"placed": des, "padsOnComp": st["pads"].as_array().map(|a| a.iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(&des)).unwrap_or(false)).count()).unwrap_or(0),+ "bodiesOnComp": if lc.map(|c| c["has3d"] == true).unwrap_or(false) { 1 } else { 0 }});+ out["_hint"] = json!(format!("{des} now carries {fp}, reopened in Fusion as {doc}. Pads not in assigns have no net. fusion_show_3d_board to see it."));+ 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(),@@ -476,6 +553,17 @@ mod tests { assert!(xml.contains(r#"<wire x1="10" y1="5.8" x2="10" y2="8" width="0.2" layer="1" />"#), "{xml}"); } + #[test]+ fn a_swap_keeps_the_place_and_rewires_by_pad_number() {+ let (first, _) = compose(&plan(None, vec![part("U1", "R0603", 3.0, 5.0, 90.0)], &[("A", &[("U1", "1")])])).unwrap();+ let (xml, r) = swap(&first, LBR, "demo", Some("u1"), "SOT23", &[("3".into(), "OUT".into()), ("9".into(), "X".into())]).unwrap();+ assert!(xml.contains(r#"<element name="U1" library="demo" package="SOT23" value="R0603" x="3" y="5" rot="R90" />"#), "{xml}");+ assert!(!xml.contains(r#"<contactref element="U1" pad="1" />"#), "old nets are gone");+ assert!(xml.contains(r#"<signal name="OUT"><contactref element="U1" pad="3" /></signal>"#));+ assert_eq!((r["padsWired"].as_u64(), r["unknownPads"][0]["pad"].as_str(), r["from"].as_str()), (Some(1), Some("9"), Some("R0603")));+ assert!(swap(&first, LBR, "demo", Some("Q7"), "SOT23", &[]).is_err());+ }+ #[test] fn outlines_refuse_bad_shapes() { assert!(outline_wires(&json!({"rect": [0, 0, 0, 5]})).is_err());
rust/bridge/src/main.rs+17−2@@ -37,6 +37,7 @@ mod maintenance; mod testproject; mod boardgen; mod partimport;+mod unsupported; mod livefeed; mod chainexec; mod epg;@@ -110,6 +111,8 @@ fn epg_params(family: &str, mut p: serde_json::Map<String, Value>) -> serde_json fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) { let bare = verb.strip_prefix("fusion_").unwrap_or(verb); let a = parsed["args"].clone();+ // Altium's own machinery: one refusal verb answers them all, with what Fusion does instead+ if unsupported::note(bare).is_some() { parsed["args"] = json!({"verb": bare}); parsed["command"] = json!("fusion_not_supported"); return ("fusion_not_supported".into(), parsed); } match bare { // the shared writer for the families it makes (no Fusion needed); Fusion's generator for the rest "generate_body" if body::engine_makes(&a) => ("fusion_generate_body_engine".into(), parsed),@@ -556,7 +559,7 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |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 mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |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 pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),@@ -682,8 +685,9 @@ 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") => 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") => 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). "fusion_aps_get" | "aps_get" => native_aps_get(&parsed["args"]), // Native since Phase 2: cloud discovery on the Data Management API.@@ -1048,6 +1052,17 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value { "clone_symbol" => a::clone_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()), "rename_symbol" => a::rename_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()), "remove_symbol" => a::remove_symbol(text.as_deref().unwrap(), &s("symbolName").or(s("name")).unwrap_or_default()),+ "set_models" => a::set_models(text.as_deref().unwrap(), &s("symbolName").unwrap_or_default(), &s("footprint").unwrap_or_default()),+ "add_models" => {+ // a model's footprint from another .lbr is copied in; its pcblib is that library+ let models: Vec<(String, Option<String>)> = args["models"].as_array().into_iter().flatten().filter_map(|m| {+ let n = m["name"].as_str()?.to_string();+ let src = m["pcblib"].as_str().or(m["libraryPath"].as_str()).map(|p| p.replace('\\', "/")).filter(|p| !p.eq_ignore_ascii_case(&path)).and_then(|p| std::fs::read_to_string(p).ok());+ Some((n, src))+ }).collect();+ if models.is_empty() { Err("models [{name, pcblib?}] is required".to_string()) }+ else { a::add_models(text.as_deref().unwrap(), &s("symbolName").unwrap_or_default(), &models, &args["params"], s("comment").as_deref(), s("description").as_deref()) }+ } "set_params" => a::set_params(text.as_deref().unwrap(), s("symbolName").as_deref(), &args["params"], s("comment").as_deref(), s("footprint").as_deref()), _ => Err(format!("unknown verb {verb}")), };
rust/bridge/src/unsupported.rsadded+57@@ -0,0 +1,57 @@+//! Standard verbs that belong to Altium's own machinery (its cloud, its script agent, its .NET+//! extension, integrated libraries, OutJobs, display modes). Fusion answers each with not_supported+//! and what Fusion does instead, through one verb, fusion_not_supported, so the catalog stays the+//! verbs that do work.++use serde_json::{json, Value};++const A365: &str = "Altium 365 is Altium's cloud; Fusion's cloud is the Autodesk hub (fusion_aps_* search and browse, fusion_open_cloud_file, fusion_save_to_cloud)";++pub const TABLE: &[(&str, &str)] = &[+ ("a365_configure", A365), ("a365_files_upload", A365), ("a365_library_overview", A365), ("a365_library_schema", A365),+ ("a365_logout", A365), ("a365_oauth_begin", A365), ("a365_oauth_cancel", A365), ("a365_oauth_status", A365),+ ("a365_query", A365), ("a365_refresh", A365), ("a365_status", A365),+ ("workspace_search", "the Altium 365 workspace library; Fusion's shared libraries are library.io (fusion_managed_library_*) and hub files (fusion_aps_search)"),+ ("workspace_place", "the Altium 365 workspace library; place a part from a library the design holds with fusion_sch_place_part"),+ ("compile_intlib", "a Fusion library is one .lbr holding symbols, footprints and components together: there is nothing to compile"),+ ("extract_intlib", "a Fusion library is one .lbr holding symbols, footprints and components together: there is nothing to extract"),+ ("outjob_info", "Fusion has no OutJob: each output is its own verb (fusion_export_gerbers with the JLCPCB CAM job, fusion_export_bom, fusion_export_pdf, fusion_export_odb ...)"),+ ("run_outjob", "Fusion has no OutJob: each output is its own verb (fusion_export_gerbers with the JLCPCB CAM job, fusion_export_bom, fusion_export_pdf, fusion_export_odb ...)"),+ ("agent_start", "Fusion has no script agent to start: the bridge's add-in loads with Fusion (fusion_start), and fusion_restart_for_addin reloads it"),+ ("agent_stop", "Fusion has no script agent: the add-in lives as long as Fusion does"),+ ("agent_recover", "Fusion has no script engine to halt: a blocking dialog is cleared with fusion_dismiss_blocking_dialogs, and fusion_addin_status says whether the add-in answers"),+ ("install_extension", "the bridge deploys its Fusion add-in itself on every start"),+ ("install_receipt", "no installer receipt: fusion_install_library_bundle answers with what it installed"),+ ("connect_workspace", "Altium's workspace picker; Fusion works in the signed-in Autodesk account's hub (fusion_account)"),+ ("selftest_script_error", "a test of Altium's DelphiScript agent; Fusion runs none"),+ ("symbol_mode", "an EAGLE symbol has one look: there are no display modes to show"),+ ("cleanup_libs", "a new Fusion library starts empty: there is no factory-default part to remove"),+ ("plate_vias", "EAGLE pads are always plated: an unplated hole is a separate hole object"),+];++pub fn note(name: &str) -> Option<&'static str> { TABLE.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) }++/// `fusion_not_supported {verb?}`: the refusal for one of these verbs, or the list.+pub fn answer(args: &Value) -> Value {+ let verb = args["verb"].as_str().unwrap_or("").trim_start_matches("fusion_").to_string();+ match note(&verb) {+ Some(n) => json!({"success": false, "errorCode": "not_supported", "verb": verb, "error": format!("{verb} is not supported in Fusion: {n}"), "_hint": n}),+ None if verb.is_empty() => json!({"success": true, "mutated": false, "verbs": TABLE.iter().map(|(n, v)| json!({"verb": n, "why": v})).collect::<Vec<_>>(),+ "_hint": "Standard verbs that belong to Altium's own machinery. Calling any of them (fusion_<verb> or the bare name) answers not_supported with what Fusion does instead."}),+ None => json!({"success": false, "errorCode": "bad_args", "error": format!("{verb} is not one of the verbs Fusion answers not_supported; call fusion_not_supported with no verb for the list")}),+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn each_altium_only_verb_is_refused_with_what_fusion_does_instead() {+ let r = answer(&json!({"verb": "fusion_run_outjob"}));+ assert_eq!((r["success"].as_bool(), r["errorCode"].as_str()), (Some(false), Some("not_supported")));+ assert!(r["error"].as_str().unwrap().contains("fusion_export_gerbers"));+ assert_eq!(answer(&json!({}))["verbs"].as_array().unwrap().len(), TABLE.len());+ assert_eq!(answer(&json!({"verb": "route_net"}))["errorCode"], "bad_args");+ }+}
rust/lbr/src/authoring.rs+102−8@@ -465,6 +465,85 @@ pub fn remove_symbol(xml: &str, name: &str) -> Result<(String, Value), String> { Ok((root.to_xml_document(), json!({"removed": n, "symbolsRemoved": gone}))) } +/// A device's connects for a footprint: each pin joined, by pad NUMBER, to every pad of that number+/// on the footprint (N and its repeats N@2, N@3 ...). The pin numbers come from the component's+/// existing connects (a repeat's @k dropped) or its ADOM_PINMAP.+fn connects_for(ds: &Element, comp: &str, fp: &str, pk_pads: &[String]) -> Result<Element, String> {+ let base = |p: &str| p.split('@').next().unwrap_or(p).to_string();+ let mut by_pin: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();+ for (num, g, p) in pin_number_map(ds) { let v = by_pin.entry((g, p)).or_default(); let b = base(&num); if !v.contains(&b) { v.push(b); } }+ if by_pin.is_empty() { return Err(format!("{comp} has no pin numbers to join to {fp}'s pads")); }+ let mut cs = Element::new("connects");+ let mut missing = Vec::new();+ for ((g, p), nums) in &by_pin {+ let pads: Vec<&String> = pk_pads.iter().filter(|q| nums.iter().any(|n| base(q) == *n)).collect();+ if pads.is_empty() { missing.extend(nums.iter().cloned()); continue; }+ cs.children.push(el("connect", &[("gate", g.clone()), ("pin", p.clone()), ("pad", pads.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "))]));+ }+ if !missing.is_empty() { return Err(format!("{fp} has no pad {}", missing.join(", "))); }+ Ok(cs)+}++/// The standard's set_models: the component keeps ONE footprint, every other device removed.+pub fn set_models(xml: &str, symbol: &str, footprint: &str) -> Result<(String, Value), String> {+ let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+ let lib = lib_mut(&mut root)?;+ let pk_names = names(section(lib, "packages"), "package");+ let fp = find_ci(&pk_names, footprint).cloned().ok_or_else(|| format!("no footprint {footprint} in this library"))?;+ let pk_pads: Vec<String> = section(lib, "packages").children.iter().find(|p| p.attr("name") == Some(fp.as_str())).map(|p| p.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| c.attr("name").map(str::to_string)).collect()).unwrap_or_default();+ let sec = section(lib, "devicesets");+ let n = find_ci(&names(sec, "deviceset"), symbol).cloned().ok_or_else(|| format!("no symbol {symbol}"))?;+ let ds = sec.children.iter_mut().find(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())).unwrap();+ let cs = connects_for(ds, &n, &fp, &pk_pads)?;+ let devs = match ds.find_child_mut("devices") { Some(d) => d, None => { ds.children.push(Element::new("devices")); ds.find_child_mut("devices").unwrap() } };+ // the attributes ride along on the kept device+ let techs = devs.children.iter().find(|d| d.tag == "device").and_then(|d| d.find_child("technologies").cloned());+ let removed = devs.children.iter().filter(|d| d.tag == "device" && d.attr("package").map(|p| !p.is_empty()).unwrap_or(false)).count();+ devs.children.retain(|d| d.tag != "device");+ let mut dev = el("device", &[("name", "".into()), ("package", fp.clone())]);+ dev.children.push(cs);+ dev.children.push(techs.unwrap_or_else(|| { let mut t = Element::new("technologies"); t.children.push(el("technology", &[("name", "".into())])); t }));+ devs.children.push(dev);+ Ok((root.to_xml_document(), json!({"component": n, "footprint": fp, "removed": removed})))+}++/// 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+/// copied from. Then params, comment and description.+pub fn add_models(xml: &str, symbol: &str, models: &[(String, Option<String>)], params: &Value, comment: Option<&str>, description: Option<&str>) -> Result<(String, Value), String> {+ let mut text = xml.to_string();+ let (mut added, mut skipped) = (Vec::new(), Vec::new());+ for (fp, source) in models {+ // a footprint from another library comes in first+ let have = { let mut r = Element::parse(&text).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?; find_ci(&names(section(l, "packages"), "package"), fp).is_some() };+ if !have {+ let src = source.as_deref().ok_or_else(|| format!("no footprint {fp} in this library (give the model's library)"))?;+ text = copy_footprint(&text, Some(src), fp, fp)?.0;+ }+ let attached = {+ let mut r = Element::parse(&text).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?;+ let sec = section(l, "devicesets");+ let ds = sec.children.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)).ok_or_else(|| format!("no symbol {symbol}"))?;+ ds.find_child("devices").map(|d| d.children.iter().any(|x| x.attr("package").map(|p| p.eq_ignore_ascii_case(fp)).unwrap_or(false))).unwrap_or(false)+ };+ if attached { skipped.push(fp.clone()); continue; }+ text = set_params(&text, Some(symbol), &Value::Null, None, Some(fp))?.0;+ added.push(fp.clone());+ }+ let (mut t, s) = set_params(&text, Some(symbol), params, comment, None)?;+ if let Some(d) = description.filter(|d| !d.is_empty()) {+ let mut r = Element::parse(&t).map_err(|e| e.to_string())?;+ { let l = lib_mut(&mut r)?; let sec = section(l, "devicesets");+ if let Some(ds) = sec.children.iter_mut().find(|x| x.tag == "deviceset" && x.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)) {+ ds.children.retain(|c| c.tag != "description"); let mut de = Element::new("description"); de.text = d.into(); ds.children.insert(0, de); } }+ t = r.to_xml_document();+ }+ let models_now = { let mut r = Element::parse(&t).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?; let sec = section(l, "devicesets");+ sec.children.iter().find(|d| d.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)).and_then(|d| d.find_child("devices")).map(|d| d.children.iter().filter(|x| x.attr("package").map(|p| !p.is_empty()).unwrap_or(false)).count()).unwrap_or(0) };+ Ok((t, json!({"libref": s["component"], "models": models_now, "added": added, "alreadyAttached": skipped, "params": s["paramsSet"]})))+}+ /// The standard's set_params: attributes on every device of the symbol (EAGLE keeps them per /// technology), a COMMENT attribute, and with `footprint` a device on that package whose connects /// come from the pin numbers (pad name = pin number).@@ -492,20 +571,13 @@ pub fn set_params(xml: &str, symbol: Option<&str>, params: &Value, comment: Opti if let Some(c) = comment { kv.push(("COMMENT".into(), c.to_string())); } let mut linked = Value::Null; if let (Some(fp), false) = (&fp_name, pk_pads.is_empty()) {- let map = pin_number_map(ds);- if map.is_empty() { return Err(format!("{n} has no pin numbers to join to {fp}'s pads")); }- let missing: Vec<&String> = map.iter().map(|(num, _, _)| num).filter(|num| !pk_pads.contains(num)).collect();- if !missing.is_empty() { return Err(format!("{fp} has no pad {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }+ let cs = connects_for(ds, &n, fp, &pk_pads)?; let devs = match ds.find_child_mut("devices") { Some(d) => d, None => { ds.children.push(Element::new("devices")); ds.find_child_mut("devices").unwrap() } }; // the package-less device that held the pin numbers becomes the footprint's device let idx = devs.children.iter().position(|d| d.tag == "device" && d.attr("package").map(|p| p.is_empty()).unwrap_or(true)); let mut dev = match idx { Some(i) => devs.children.remove(i), None => el("device", &[("name", if devs.children.is_empty() { "".into() } else { format!("-{}", fp) })]) }; set(&mut dev, "package", fp); dev.children.retain(|c| c.tag != "connects");- let mut cs = Element::new("connects");- let mut by_pin: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();- for (num, g, p) in &map { by_pin.entry((g.clone(), p.clone())).or_default().push(num.clone()); }- for ((g, p), pads) in &by_pin { cs.children.push(el("connect", &[("gate", g.clone()), ("pin", p.clone()), ("pad", pads.join(" "))])); } dev.children.insert(0, cs); devs.children.push(dev); linked = json!(fp);@@ -542,6 +614,28 @@ pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) { (errs.is_empty(), errs) } +#[cfg(test)]+mod model_tests {+ use super::*;++ #[test]+ fn models_are_added_once_and_set_keeps_one() {+ let fp = "META|A2|two\nPAD|1|-1|0|1|1|rect|0|top|0\nPAD|2|1|0|1|1|rect|0|top|0\nPAD|2|1|1|0.5|0.5|rect|0|top|0\n";+ let fp_b = "META|B2|two\nPAD|1|-2|0|1|1|rect|0|top|0\nPAD|2|2|0|1|1|rect|0|top|0\n";+ let sym = "META|R-X|res|R\nMODE|0|n\nPIN|0|1|1|0|0|left|100||passive\nPIN|0|2|2|200|0|right|100||passive\n";+ let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();+ let (other, _) = crate::import::import_part(None, Some(fp_b), None, None, &json!({})).unwrap();+ let (two, r) = add_models(&xml, "R-X", &[("B2".into(), Some(other.clone())), ("A2".into(), None)], &json!({"MPN": "X1"}), None, Some("a resistor")).unwrap();+ assert_eq!((r["models"].as_u64(), r["added"][0].as_str(), r["alreadyAttached"][0].as_str()), (Some(2), Some("B2"), Some("A2")), "{r}");+ assert!(two.contains(r#"<connect gate="G$1" pin="2" pad="2" />"#) && two.contains(r#"pad="2 2@2""#), "{two}");+ let (one, r) = set_models(&two, "r-x", "B2").unwrap();+ assert_eq!((r["removed"].as_u64(), r["footprint"].as_str()), (Some(2), Some("B2")));+ assert_eq!(one.matches("<device ").count(), 1);+ assert!(one.contains(r#"name="MPN" value="X1""#), "params ride on the kept device");+ assert!(set_models(&one, "R-X", "NOPE").is_err());+ }+}+ #[cfg(test)] mod layout_tests { use super::*;
tests/run_verb_tests.py+5@@ -447,6 +447,11 @@ IMPORT_FP = 'META|SOT-23-5-LANE|SOT-23-5, 0.95 mm pitch\nPAD|1|-1.1|0.95|1.06|0. IMPORT_SYM = 'META|LDO-LANE|300 mA LDO (fusion-lane test)|U\nMODE|0|std\nRECT|0|0|-400|800|0\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|3|EN|0|-200|left|300||input\nPIN|0|2|GND|400|-400|down|300||power_in\nPIN|0|5|OUT|800|-100|right|300||power_out\nPIN|0|4|NC|800|-300|right|300||no_connect\n' t("fusion_import_part", "daily", {"libraryPath": BOARDDIR + "/import.lbr", "footprintSpec": IMPORT_FP, "symbolSpec": IMPORT_SYM, "params": {"MPN": "LANE-LDO-TEST"}, "dryRun": True}, {"ok": True, "fields": ["footprint", "symbol", "steps"]}, "Altium-format specs into one .lbr: footprint, symbol, connects, params (dry run, nothing written)") t("fusion_import_part#nothing", "negative", {"libraryPath": BOARDDIR + "/import.lbr"}, {"pred": "must_refuse"}, "nothing to import is not success")+t("fusion_not_supported", "daily", {}, {"ok": True, "fields": ["verbs"]}, "the Altium-only standard verbs and what Fusion does instead")+t("fusion_not_supported#a365", "negative", {"verb": "a365_status"}, {"pred": "must_refuse"}, "an Altium-only verb answers not_supported")+t("fusion_set_models", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "footprint": "SOT-23-5-LANE", "dryRun": True}, {"ok": True, "fields": ["data"]}, "one footprint kept (dry run)")+t("fusion_add_models", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "models": [{"name": "SOT-23-5-LANE"}], "dryRun": True}, {"ok": True, "fields": ["data"]}, "an attached footprint is skipped, not stacked (dry run)")+t("fusion_swap_footprint", "daily", {"libraryPath": BOARDLIB, "footprintName": "SOT23", "pcbPath": BOARDDIR + "/test.brd", "designator": "U1", "assigns": "1=IN;2=GND;3=OUT", "open": False}, {"ok": True, "fields": ["swap"]}, "U1 on the battery's test board takes SOT23 (offline)") 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")