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 13 file(s)
13 files changed
+87−18
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.84+1.13.85
CHANGELOG.md+4@@ -3,6 +3,10 @@ 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.85 (2026-11-08)++`validateOnly: true` and `dryRun: true` mean the same thing on every verb (#146, reported by Glenn Jones). `fusion_sch_place_part` takes `dryRun`; given `validateOnly` (the name `fusion_schematic_run` uses) it did not know the flag and placed the part, on the schematic and the board. Either flag now sets both, so a verb with a dry run honours whichever one is sent, and a verb with no dry run refuses the flag with `dry_run_not_supported` before Fusion is touched, instead of doing the real thing. The catalog now lists `validateOnly` on `fusion_schematic_run` and `dryRun` on `fusion_variants`. Exe only: no Fusion restart.+ ## 1.13.84 (2026-11-06) `render_board_3d` with `quality:"high"` really stops a render that runs past `renderSeconds` (found by Drew Owens on PR #40). It called `RenderFuture.cancel()`, which does not exist; the error was swallowed and the reply said "it was cancelled" while the render kept going and could write `outputPath` later. Now it ends that render's own RenderProcess.exe through the same `render_process.cancel` as `render_image` (embedded in the electronics script), renders into a staging folder and moves the image to `outputPath` only on success, so an existing image is no longer deleted first and a late image can never land there. The reply carries `cancel: {cancelled, renderState, reason}` and says plainly when a render could not be stopped. Exe only: no Fusion restart.
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.84",+ "version": "1.13.85", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+4−2@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-11-05",+ "frozen": "2026-11-08", "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.", "source": "describe.py", "verbs": [@@ -23894,7 +23894,8 @@ "commands": "required [command] (1..400)", "expectDocument": "optional exact schematic name", "libraryPath": "required for ADD: the .lbr on this machine (its file name is the library name in ADD)",- "pauseMs": "optional 0..3000 between commands"+ "pauseMs": "optional 0..3000 between commands",+ "validateOnly": "bool: check every command and the library, run nothing (dryRun means the same)" }, "longRunning": false, "name": "fusion_schematic_run",@@ -25070,6 +25071,7 @@ { "example": {}, "input": {+ "dryRun": "bool: plan only (validateOnly means the same)", "expectDocument": "the design", "name": "a variant name: refused (not_supported), see above" },
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.84"+version = "1.13.85" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.84"+version = "1.13.85" edition = "2021" [dependencies]
describe.py+2−2@@ -435,7 +435,7 @@ _T = [ {"component": "str", "parameter": "str", "visible": "bool", "symbolsChanged": "[str]"}, 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO", "parameter": "MPN", "visible": True, "dryRun": True}), ('fusion_variants', "Assembly variants of the open design (the standard's variants): the list, each variant with its not-fitted parts and alternate values, read from the schematic. Making, changing or removing one is refused with not_supported: Fusion takes EAGLE's VARIANT from a text command and changes nothing (checked live on Fusion 2706), so those are done in Fusion's schematic editor.",- {"expectDocument": "the design", "name": "a variant name: refused (not_supported), see above"},+ {"expectDocument": "the design", "name": "a variant name: refused (not_supported), see above", "dryRun": "bool: plan only (validateOnly means the same)"}, {"variants": "[{name, current, notFitted, alternates}]", "variant": "the one edited, as read back"}, 120, None, False, {}), ('fusion_silkscreen_prep', "Get the silkscreen off the copper (the standard's silkscreen_prep, in Fusion's terms): designators on pads are moved off them where they fit (the shared engine's silk_designators plan, applied and read back), counted before and after; loose silk outside the board is listed. A footprint's own silk lines are its library package's in EAGLE, so they cannot be clipped on one board: the fab's CAM clips silkscreen at mask openings (JLCPCB does). dryRun counts and plans only.",@@ -663,7 +663,7 @@ _T = [ {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","nets":"required [net name]"}, {"success":"bool","verified":"bool","ripped":"[net]","revision":"str"},120,"fusion_routing_state",False,{}), ("fusion_schematic_run", "Build a schematic live: a list of complete, non-interactive EAGLE commands run one at a time on the active schematic (ADD 'dev@lib' 'NAME' R0 (x y), VALUE, NET 'name' (pts), LABEL, WIRE, TEXT, LAYER, CHANGE STYLE/SIZE/WIDTH/RATIO, WINDOW FIT), with an optional pause between them so a viewer sees it grow. Anything else is refused before the first command runs. A list of any length: past 300 commands the bridge checks all of it first, then runs it in batches (batches; on a failure failedAt in your list, batchesDone). The call answers with the whole reply when it finishes within about 100 s; otherwise it answers stillRunning with a runId, and fusion_schematic_run {runId} polls until stillRunning is false, which is the whole reply. ADD is proven against libraryPath on disk first (the deviceset must be there; the library is USEd by the bridge), so the component picker can never open. A .lbr Fusion 2706 cannot ADD from (no line breaks between elements, a byte-order mark, single-quoted attributes, a 3D package naming a footprint the file does not have) is rewritten in place first, keeping its element tree apart from those dead 3D entries (libraryReformatted, library3dEntriesRemoved). The bridge sends USE itself, for the file named in libraryLoaded. Readback: the parts and nets on the sheet from a fresh export.",- {"expectDocument":"optional exact schematic name","libraryPath":"required for ADD: the .lbr on this machine (its file name is the library name in ADD)","commands":"required [command] (1..400)","pauseMs":"optional 0..3000 between commands"},+ {"expectDocument":"optional exact schematic name","libraryPath":"required for ADD: the .lbr on this machine (its file name is the library name in ADD)","commands":"required [command] (1..400)","pauseMs":"optional 0..3000 between commands","validateOnly":"bool: check every command and the library, run nothing (dryRun means the same)"}, {"success":"bool","verified":"bool","parts":"[name]","added":"[name]","nets":"{net:[part.pin]}","commands":"int","libraryLoaded":"path","libraryReformatted":"bool","library3dEntriesRemoved":"[name]"},120,"fusion_get_app_state",False,{}), ("fusion_update_pcb", "The standard's update_pcb. Fusion forward-annotates the board from the schematic as you edit, so there is no change order to run: the preview compares the sheet and the board (addParts on the sheet with no package, removeParts only on the board), and execute has nothing to carry over. allowRemovals with parts to remove is refused: Fusion cannot delete a part from a linked board by command.", {"expectDocument":"the design (default the one in front)","execute":"bool, default false","allowRemovals":"bool, default false"},
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.84"+DASH_VERSION="1.13.85" 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.84",+ "version": "1.13.85", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+2−2@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.84",+ "version": "1.13.85", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -152,4 +152,4 @@ "confirmed_user_skills": [ "fusion-design-efficiency" ]-}+}
rust/Cargo.lock+5−5@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.84"+version = "1.13.85" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.84"+version = "1.13.85" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.84"+version = "1.13.85" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.84"+version = "1.13.85" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.84"+version = "1.13.85" 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.84"+version = "1.13.85" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+63@@ -111,6 +111,32 @@ fn epg_params(family: &str, mut p: serde_json::Map<String, Value>) -> serde_json /// generate_body -> fusion_generate_package (family -> type, dimensionsMm -> params with pins as /// the generator's per-side DPins/EPins, marking -> etch, markingStyle -> etchStyle, outputPath -> /// outputStep). (library_views is docs.rs: it opens the library first.)+/// The two names callers use for "check, change nothing" (dryRun on most edit verbs, validateOnly on+/// schematic_run) mean the same thing here: either one sets both, so a verb that honours one honours the+/// other. A verb the catalog lists with neither is refused before Fusion is touched (#146: sch_place_part+/// took validateOnly:true as unknown and placed the part).+fn dry_flags(verb: &str, mut parsed: Value) -> (Value, Option<Value>) {+ let args = &parsed["args"];+ let asked: Vec<&str> = ["dryRun", "validateOnly"].into_iter().filter(|k| args[*k].as_bool() == Some(true)).collect();+ if asked.is_empty() { return (parsed, None); }+ let name = if verb.starts_with("fusion_") { verb.to_string() } else { format!("fusion_{verb}") };+ static DRY: std::sync::OnceLock<(std::collections::HashSet<String>, std::collections::HashSet<String>)> = std::sync::OnceLock::new();+ let (known, dry) = DRY.get_or_init(|| {+ let cat: Value = serde_json::from_str(include_str!("../../../contracts/describe.json")).unwrap_or(json!({}));+ let verbs = cat["verbs"].as_array().cloned().unwrap_or_default();+ let name_of = |v: &Value| v["name"].as_str().unwrap_or("").to_string();+ (verbs.iter().map(name_of).collect(),+ verbs.iter().filter(|v| v["input"].get("dryRun").is_some() || v["input"].get("validateOnly").is_some()).map(name_of).collect())+ });+ if known.contains(&name) && !dry.contains(&name) {+ return (parsed, Some(json!({"success": false, "mutated": false, "errorCode": "dry_run_not_supported", "shell": "rust",+ "error": format!("{name} has no dry run, so {} was not honoured and nothing was done.", asked.join(" and ")),+ "_hint": format!("Call {name} without {} to run it for real; the verbs that take dryRun (or validateOnly) list it in fusion_describe.", asked.join("/"))})));+ }+ if let Some(a) = parsed["args"].as_object_mut() { a.insert("dryRun".into(), json!(true)); a.insert("validateOnly".into(), json!(true)); }+ (parsed, None)+}+ fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) { let bare = verb.strip_prefix("fusion_").unwrap_or(verb); let a = parsed["args"].clone();@@ -500,7 +526,12 @@ fn main() { // the standard's generate_body and library_views, in this bridge's own verbs' terms let (verb, parsed) = standard_adapter(&verb, parsed); let raw = if verb.as_str() == asked.as_str() || standard_alias(&asked).is_some() { raw } else { parsed.to_string() };+ // dryRun and validateOnly are one flag; a verb with no dry run refuses it (#146)+ let before = parsed["args"].clone();+ let (parsed, dry_refusal) = dry_flags(&verb, parsed);+ let raw = if parsed["args"] == before { raw } else { let mut p: Value = serde_json::from_str(&raw).unwrap_or(json!({})); p["args"] = parsed["args"].clone(); p.to_string() }; let reply = match verb.as_str() {+ _ if dry_refusal.is_some() => dry_refusal.clone().unwrap(), // Mechanical history is the exe's own journal: observational even when Fusion is // cold, blocked or an owner preference selects the legacy sidecar. v if mech::owns_status(v) => { let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let mut r = mech::status(&h.mechanical_state_dir(), &pids, &parsed["args"]); r["shell"] = json!("rust"); r },@@ -1897,6 +1928,38 @@ mod lint_library_tests { } } +#[cfg(test)]+mod dry_flag_tests {+ use serde_json::json;+ #[test]+ fn validate_only_reaches_a_verb_that_takes_dry_run() {+ // #146: sch_place_part takes dryRun; validateOnly from schematic_run's vocabulary now means the same+ let (p, refused) = super::dry_flags("fusion_sch_place_part", json!({"command": "fusion_sch_place_part", "args": {"validateOnly": true, "symbol": "C"}}));+ assert!(refused.is_none());+ assert_eq!((p["args"]["dryRun"].clone(), p["args"]["validateOnly"].clone(), p["args"]["symbol"].clone()), (json!(true), json!(true), json!("C")));+ let (p, refused) = super::dry_flags("schematic_run", json!({"args": {"dryRun": true, "commands": []}}));+ assert!(refused.is_none());+ assert_eq!(p["args"]["validateOnly"], json!(true));+ }+ #[test]+ fn a_verb_without_a_dry_run_refuses_the_flag_and_does_nothing() {+ let (_, refused) = super::dry_flags("fusion_move_parts", json!({"args": {"validateOnly": true, "parts": []}}));+ let r = refused.expect("refused");+ assert_eq!((r["errorCode"].clone(), r["mutated"].clone()), (json!("dry_run_not_supported"), json!(false)));+ assert!(r["error"].as_str().unwrap().contains("validateOnly"));+ }+ #[test]+ fn no_flag_or_false_or_an_unknown_verb_passes_untouched() {+ for args in [json!({"x": 1}), json!({"dryRun": false}), json!({"validateOnly": false})] {+ let (p, refused) = super::dry_flags("fusion_move_parts", json!({"args": args.clone()}));+ assert!(refused.is_none());+ assert_eq!(p["args"], args);+ }+ let (_, refused) = super::dry_flags("bridge_install", json!({"args": {"dryRun": true}}));+ assert!(refused.is_none());+ }+}+ #[cfg(test)] mod standard_alias_tests { #[test]