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 164 file(s)
17 files changed
+45−49
BRIDGE_VERSION+1−1bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+10−26rust/Cargo.toml+1−1rust/bridge/src/lifecycle.rs+2−3rust/bridge/src/main.rs+1−1rust/bridge/src/orchestrations.rs+3−1rust/bridge/src/small_verbs.rs+18−10rust/core/src/dialogs.rs+4−1tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.shBRIDGE_VERSION+1−1@@ -1 +1 @@-1.10.40+1.10.41
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.10.40",+ "version": "1.10.41", "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.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.10.40"+version = "1.10.41" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.40"+version = "1.10.41" edition = "2021" [dependencies]
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.10.40"+DASH_VERSION="1.10.41" 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.10.40",+ "version": "1.10.41", "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+10−26@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.40",+ "version": "1.10.41", "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": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -76,6 +80,9 @@ "add a 3d model to a component", "make a fusion 360 library with 3d", "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page", "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company",@@ -108,28 +115,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
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.10.40"+version = "1.10.41" edition = "2021" license = "MIT"
rust/bridge/src/lifecycle.rs+2−3@@ -212,9 +212,8 @@ pub fn dialog_inventory(ctx: &Ctx) -> Vec<Value> { for d in fusion_core::dialogs::qt_dialog_rows(&rows) { let h = d["hwnd"].as_i64().unwrap_or(0); let (mut text, mut buttons): (Vec<String>, Vec<String>) = (vec![], vec![]);- if let Ok(r) = ab.call("desktop_find_controls", json!({"hwnd": h}), &ctx.caller, 10) {- let dd = if r["data"].is_object() { r["data"].clone() } else { r };- for c in dd["controls"].as_array().or(dd["matches"].as_array()).cloned().unwrap_or_default() {+ {+ for c in crate::small_verbs::controls_cached(&ab, &ctx.caller, h) { let name = c["name"].as_str().unwrap_or("").trim().to_string(); if name.is_empty() { continue; } let ty = c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase();
rust/bridge/src/main.rs+1−1@@ -478,7 +478,7 @@ fn main() { let mut r = match cmd { "stop" => lifecycle::stop(&ctx, &parsed["args"]), "kill" => lifecycle::kill(&ctx, &parsed["args"]), _ => lifecycle::restart_for_addin(&ctx, &parsed["args"], &|verb, a| addin::pass_through(verb, &a, &ctx)) }; timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- "fusion_get_preferences" | "get_preferences" 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 mut r = fusion_prefs::get_preferences(&|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },+ "fusion_get_preferences" | "get_preferences" 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 _ = &ctx; let mut r = fusion_prefs::get_preferences(&|v, a, t| addin::addin_http(v, &a, t.max(20))); r["shell"] = json!("rust"); r }, "fusion_set_preference" | "set_preference" 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 mut r = fusion_prefs::set_preference(&parsed["args"], &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r }, "fusion_reset_workspace" | "reset_workspace" 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 mut r = fusion_prefs::reset_workspace(&sd, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx), &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r }, "fusion_describe" | "describe" => {
rust/bridge/src/orchestrations.rs+3−1@@ -618,7 +618,9 @@ if hit is not None: hit.activate() result = {{"wanted": want, "tabType": {want_pt:?}, "switchedTab": hit is not None, "activeNow": app.activeDocument.name if app.activeDocument else None}} "#);- let sr = pass("run_modeling_script", json!({"script": script}));+ // straight to the add-in: this is the bridge's own bookkeeping, not a user script, so it skips+ // the window-etiquette envelope (its dialog scans before and after cost more than the script)+ let sr = crate::addin::addin_http("run_modeling_script", &json!({"script": script}), 20); let res = if sr["data"]["result"].is_object() { sr["data"]["result"].clone() } else { sr["result"].clone() }; let switched = res["switchedTab"].as_bool() == Some(true); // no typed tab matched (a standalone local .sch/.brd, or an older Fusion): fall back to name
rust/bridge/src/small_verbs.rs+18−10@@ -333,6 +333,20 @@ pub fn screenshot_fusion(args: &Value, caller: &fusion_core::ab::Caller) -> Valu } } +/// UI Automation reads of a window's controls cost ~1-5 s on a busy Fusion, and the classifier,+/// the closable check and the inventory each wanted one for the same window on the same verb.+/// One read per window per 20 s is plenty: a dialog's buttons do not change that fast.+pub fn controls_cached(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Vec<Value> {+ use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant};+ static CACHE: Mutex<Option<HashMap<i64, (Instant, Vec<Value>)>>> = Mutex::new(None);+ if let Some((t, v)) = CACHE.lock().unwrap().get_or_insert_with(HashMap::new).get(&hwnd) { if t.elapsed() < Duration::from_secs(20) { return v.clone(); } }+ let v: Vec<Value> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default() }).unwrap_or_default();+ let mut g = CACHE.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);+ m.retain(|_, (t, _)| t.elapsed() < Duration::from_secs(60));+ m.insert(hwnd, (Instant::now(), v.clone()));+ v+}+ /// ab's image-delivery contract: a reply that carries `fullPath` (and `safePath`), or /// `screenshots[]` entries with `fullPath`, is auto-pulled to the CALLER's machine, so an AI in a /// container can read the picture with no second call. Older replies named the Windows path@@ -386,12 +400,9 @@ pub fn unknown_window_is_closable(ab: &dyn fusion_core::ab::AbClient, caller: &f if mw > 0 && mh > 0 && w * 10 >= mw * 8 && h * 10 >= mh * 8 { return (false, format!("window-sized ({w}x{h} against the main window's {mw}x{mh}): a main or document window, not a dialog")); } } let Some(hwnd) = dlg["hwnd"].as_i64() else { return (false, "no window handle".into()) };- let buttons: Vec<String> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| {- let d = if r["data"].is_object() { r["data"].clone() } else { r };- d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default().iter()- .filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))- .filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect()- }).unwrap_or_default();+ let buttons: Vec<String> = controls_cached(ab, caller, hwnd).iter()+ .filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))+ .filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect(); const DISMISS: &[&str] = &["ok", "cancel", "close", "no", "dismiss", "not now", "later", "skip", "got it", "continue"]; if buttons.iter().any(|b| DISMISS.iter().any(|d| b == d || b.starts_with(&format!("{d} ")))) { (true, String::new()) } else { (false, format!("no Cancel/Close/OK-style button (buttons: {})", if buttons.is_empty() { "none readable".to_string() } else { buttons.join(", ") })) }@@ -410,10 +421,7 @@ pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fu let mut matched_on: Option<&str> = None; if classified.as_ref().map(|(c, _)| c == "generic_modal_read_screenshot" || c == "unknown").unwrap_or(true) { if let Some(h) = dlg["hwnd"].as_i64() {- let body: String = ab.call("desktop_find_controls", json!({"hwnd": h}), caller, 10).ok()- .map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; let ctrls = d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default();- ctrls.iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect() })- .unwrap_or_default();+ let body: String = controls_cached(ab, caller, h).iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect(); if body.contains("ToolbarFramework") || body.contains("QTView") { continue; } // a Fusion panel (a floating BROWSER titled "Fusion360"), not a dialog if !body.is_empty() { if let Some(bt) = fusion_core::dialogs::category_for_title(&body) { classified = Some(bt); matched_on = Some("bodyText"); } } }
rust/core/src/dialogs.rs+4−1@@ -54,7 +54,10 @@ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> { if title.contains("QTView") { continue; } let rect = rect_of(&w["rect"]); let (ww, hh) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));- if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww * 3 / 2) { continue; }+ // tool windows shaped like a PANEL (taller than wide: a floating BROWSER is 600x800 and titled+ // just "Fusion360") are docked-panel frames, never modal dialogs. Skipping them here also saves+ // the seconds a UI Automation read of their contents costs on every verb (2026-09-22, #97).+ if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww) { continue; } out.push(serde_json::json!({"hwnd": w["hwnd"], "title": title, "className": class, "rect": rect})); } out
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh