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 129 file(s)
11 files changed
+166−42
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.355+1.9.356
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.355",+ "version": "1.9.356", "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.9.355"+version = "1.9.356" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.355"+version = "1.9.356" 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.9.355"+DASH_VERSION="1.9.356" 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.9.355",+ "version": "1.9.356", "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+11−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.355",+ "version": "1.9.356", "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",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "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.",@@ -104,28 +111,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.9.355"+version = "1.9.356" edition = "2021" license = "MIT"
rust/PORTING.md+21@@ -172,3 +172,24 @@ in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling l Python bridge learned on 2026-08-29. Now each request runs on its own thread (tiny_http hands them out concurrently); a blocking `fusion_start` through the exe produced no respawn and readiness agreed afterwards.+- **The registry lints every `.lbr` in the repo tree on `pkg publish`**, not only the files in+ `page.json.files`. Golden and fixture libraries therefore carry the `.lbrx` extension+ (they are deliberately broken or bound to a test account's cloud), and shipped samples+ carry no `<package3d>` urn (a urn is a pointer into the minting account, nothing for+ anyone else). `adom-lbr validate <file>` shows the same findings locally.+- **Readiness ownership switch (2026-09-03).** `prefs.json` `readinessOwner: "exe"` makes the+ exe serve `fusion_readiness` itself: it gathers, acts (seat dialog and update click through+ the sidecar's `resolve_seat_dialog` / `apply_fusion_update`, the crash janitor through+ `dismiss_blocking_dialogs {categories:["crash_report"]}`, launch self-heal through+ `stop`/`kill` + `start`, launch timing samples and pref writes natively), computes, and+ answers with `readinessOwner: "exe"`; the sidecar is not asked. Any other value (or no+ key) keeps the sidecar as owner with the exe shadowing beside it. Roll back by deleting+ the key. One owner at a time: the sidecar's own sweeps (seat takeover, update sweep) keep+ running in both modes, as before.+- **Owned readiness proven (ConfRoomROG, 2026-09-03):** with `readinessOwner: "exe"` the exe+ served every readiness poll through a real stop/start: the launch progress block ("Fusion is+ up - waiting for the Adom add-in to serve"), ready afterwards, the signed-in user fetched+ once and cached, `launchStartedAt` cleared, `lastReadyAt` written, and a new cold-start+ sample (38.5 s) in `timings.json`. Set the pref WITHOUT a byte-order mark: PowerShell's+ `Set-Content -Encoding UTF8` writes one, the Python bridge's pref loader then fails and+ rewrites the file without the key (the first attempt silently never engaged).
rust/bridge/Cargo.toml+1@@ -10,6 +10,7 @@ name = "adom-fusion-bridge" path = "src/main.rs" [dependencies]+regex = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } fusion-core = { path = "../core" } fusion-lbr = { path = "../lbr" }
rust/bridge/src/main.rs+126−8@@ -318,6 +318,7 @@ fn main() { let reply = match verb.as_str() { // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness // from real probes and rides beside it with a diff, until parity holds.+ "fusion_readiness" | "readiness" if readiness_owner_is_exe(&**h) => owned_readiness(&**h, &sidecar, &caller), "fusion_readiness" | "readiness" => { let mut r = proxy(&sidecar, &raw, &caller); let native = shadow_readiness(&**h, &caller);@@ -848,10 +849,131 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> /// Gather every input the readiness computation needs from real probes (no side effects), /// and compute the native answer. Shadow mode: nothing is written, nothing is acted on.+/// Who serves `fusion_readiness`: the sidecar (default, the exe shadows) or the exe+/// (`prefs.json` `readinessOwner: "exe"`). One owner at a time, never both acting.+fn readiness_owner_is_exe(h: &dyn HostBoth) -> bool {+ std::fs::read_to_string(h.bridge_state_dir().join("prefs.json")).ok().and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok())+ .map(|p| p["readinessOwner"].as_str() == Some("exe")).unwrap_or(false)+}++/// The signed-in Fusion user, fetched once through the add-in in the background and cached+/// until Fusion stops (the dashboard shows who is signed in; the auth matrix runs accounts).+static FUSION_USER: Mutex<Option<Value>> = Mutex::new(None);+static FUSION_USER_FETCHING: Mutex<bool> = Mutex::new(false);++fn ensure_fusion_user_async(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) {+ if FUSION_USER.lock().unwrap().is_some() || *FUSION_USER_FETCHING.lock().unwrap() { return; }+ *FUSION_USER_FETCHING.lock().unwrap() = true;+ let (sidecar, caller) = (sidecar.clone(), caller.clone());+ std::thread::spawn(move || {+ let body = json!({"command": "fusion_run_modeling_script", "args": {"script": "import adsk.core\na=adsk.core.Application.get()\nresult={'user': a.userName, 'id': a.userId}"}}).to_string();+ let r = proxy(&sidecar, &body, &caller);+ let d = &r["data"]["result"];+ if let Some(u) = d["user"].as_str() { *FUSION_USER.lock().unwrap() = Some(json!({"user": u, "userId": d["id"]})); }+ *FUSION_USER_FETCHING.lock().unwrap() = false;+ });+}++/// The user's Default Modeling Orientation from Fusion's local options file (disk only).+fn modeling_orientation(user_id: Option<&str>) -> Option<String> {+ let uid = user_id?;+ let appdata = std::env::var("APPDATA").ok()?;+ let p = std::path::Path::new(&appdata).join("Autodesk").join("Neutron Platform").join("Options").join(uid).join("NGlobalOptions.xml");+ let t = std::fs::read_to_string(p).ok()?;+ let re = regex::Regex::new(r#"sceneUpOptionId[^>]*Value="(\d)""#).ok()?;+ match re.captures(&t)?.get(1)?.as_str() { "0" => Some("yup".into()), "1" => Some("zup".into()), _ => None }+}++/// `timings.record("launch", secs, "cold")`: a rolling ten samples per phase and start kind.+fn record_launch(h: &dyn HostBoth, secs: f64) {+ let p = h.bridge_state_dir().join("timings.json");+ let mut d: Value = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ let key = "launch|cold";+ let mut samples: Vec<Value> = d[key]["samples"].as_array().cloned().unwrap_or_default().into_iter().filter(|v| v.is_number()).collect();+ samples.push(json!((secs * 10.0).round() / 10.0));+ let keep: Vec<Value> = samples.iter().rev().take(10).rev().cloned().collect();+ d[key] = json!({"samples": keep, "lastAt": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0)});+ let tmp = p.with_extension("json.tmp");+ if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// Merge the computation's pref writes into prefs.json (other keys untouched).+fn write_prefs(h: &dyn HostBoth, after: &serde_json::Map<String, Value>) {+ let p = h.bridge_state_dir().join("prefs.json");+ let mut d: Value = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ if !d.is_object() { d = json!({}); }+ for (k, v) in after { d[k] = v.clone(); }+ let tmp = p.with_extension("json.tmp");+ if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// The exe OWNS readiness: gather, act where the Python handler would have acted (through+/// the sidecar's executor verbs and its own verbs), compute, persist, and answer.+fn owned_readiness(h: &dyn HostBoth, sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> Value {+ use fusion_core::readiness::compute;+ let mut inputs = gather_readiness_inputs(h, caller);+ let call = |verb: &str, args: Value| -> Value { proxy(sidecar, &json!({"command": verb, "args": args}).to_string(), caller) };+ let mut seat_resolved = false;+ // the seat / licensing modal: resolve now, then look again (like the Python handler)+ if inputs.running && inputs.licensing_dialog {+ let r = call("resolve_seat_dialog", json!({}));+ seat_resolved = r["clicks"].as_u64().unwrap_or(0) > 0;+ inputs = gather_readiness_inputs(h, caller);+ }+ // the crash-report janitor: clear crash dialogs while Fusion is up but not serving+ let provisional_ready = inputs.installed_raw && inputs.running && inputs.addin_version.is_some() && !inputs.needs_signin && !inputs.licensing_dialog;+ if inputs.running && !provisional_ready {+ let r = call("dismiss_blocking_dialogs", json!({"categories": ["crash_report"]}));+ inputs.crash_dialogs_cleared = r["count"].as_u64().unwrap_or(0) as usize;+ }+ // the silent update click happens BEFORE the hint is composed, like Python+ if provisional_ready {+ let r = call("apply_fusion_update", json!({}));+ inputs.update_offered = r["offered"].as_bool().unwrap_or(false);+ if r["applied"].as_bool() == Some(true) { inputs.update_applied_button = r["button"].as_str().map(str::to_string); }+ }+ // who is signed in, and their modeling orientation+ if provisional_ready { ensure_fusion_user_async(sidecar, caller); } else if !inputs.running { *FUSION_USER.lock().unwrap() = None; }+ inputs.fusion_user = FUSION_USER.lock().unwrap().clone();+ inputs.modeling_orientation = modeling_orientation(inputs.fusion_user.as_ref().and_then(|u| u["userId"].as_str()));+ let out = compute(&inputs);+ write_prefs(h, &out.prefs_after);+ if let Some(secs) = out.actions.record_launch_seconds { record_launch(h, secs); }+ if let Some(attempt) = out.actions.heal_launch {+ let (sidecar, caller) = (sidecar.clone(), caller.clone());+ std::thread::spawn(move || {+ let c = |verb: &str| proxy(&sidecar, &json!({"command": verb, "args": {}}).to_string(), &caller);+ if attempt >= 2 { c("kill"); } else { c("stop"); }+ std::thread::sleep(Duration::from_secs(8));+ c("start");+ });+ eprintln!("[fusion-bridge] add-in never came up; restarting Fusion (attempt {attempt})");+ }+ let mut r = out.result;+ if seat_resolved { r["seatDialogAutoResolved"] = json!(true); }+ r["readinessOwner"] = json!("exe");+ r+}+ fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value {- use fusion_core::readiness::{compute, timings_stats, timings_summary, Inputs};+ use fusion_core::readiness::compute;+ let inputs = gather_readiness_inputs(h, caller);+ let facts_degraded = inputs.fusion_user.is_none() && false; // kept for the field below+ let out = compute(&inputs);+ let mut r = out.result;+ r["shadow"] = json!(true);+ r["actions"] = serde_json::to_value(&out.actions).unwrap_or(Value::Null);+ r["windowFactsDegraded"] = json!(WINDOW_FACTS_DEGRADED.lock().unwrap().0 || facts_degraded);+ r+}++/// The last window-facts fetch: (degraded, error) so both readiness paths can report it.+static WINDOW_FACTS_DEGRADED: Mutex<(bool, Option<String>)> = Mutex::new((false, None));++fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::readiness::Inputs {+ use fusion_core::readiness::{timings_stats, timings_summary, Inputs}; let state = h.bridge_state_dir();- let read_json = |p: std::path::PathBuf| -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({})) };+ let read_json = |p: std::path::PathBuf| -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(t.trim_start_matches('\u{feff}')).ok()).unwrap_or(json!({})) }; let prefs = read_json(state.join("prefs.json")).as_object().cloned().unwrap_or_default(); let timings = read_json(state.join("timings.json")); let installed_raw = h.fusion_executable().is_some();@@ -890,10 +1012,6 @@ fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value { timings_launch: timings_summary(&timings)["launch"].clone(), launch_stats: timings_stats(&timings, "launch", "cold"), progress_override: None, addin_start_failed, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false, };- let out = compute(&inputs);- let mut r = out.result;- r["shadow"] = json!(true);- r["actions"] = serde_json::to_value(&out.actions).unwrap_or(Value::Null);- r["windowFactsDegraded"] = json!(facts.degraded);- r+ *WINDOW_FACTS_DEGRADED.lock().unwrap() = (facts.degraded, facts.error.clone());+ inputs }