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
DisplayLink warning on readiness, tour and import; the EAGLE import stops on a crash dialog (issue #121)
4 files changed
+120−5
CHANGELOG.md+9@@ -64,6 +64,15 @@ The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bri export, in the background, and switches to the board itself when the schematic or 3D view is in front. The same names now feed `fusion_board_bom` (which read only `LCSC_PN`) and `fusion_component_data`. Live on BQ25792: 109 parts, 45 lines, LCSC on 63 parts, MPN on 28.+- **DisplayLink warning** (page issue #121): when Fusion has detected an indirect display adapter+ (`IndirectDisplayAdapterDetected=1` in its `NMachineSpecificOptions.xml`), Fusion Electronics can die in its+ D3D12 canvas on any monitor. `fusion_readiness` now carries `displayWarning`, and the guided tour and+ `fusion_new_electronics_from_eagle` say so (reply and hint) before they start.+- **The EAGLE import stops on a crash dialog** (page issue #121): `fusion_new_electronics_from_eagle` checks for+ Fusion's D3D12 device-loss and "unexpected condition" dialogs while it waits for each Open dialog, before it+ types a path, while the path is submitted and while the design comes up. It stops at once, cancels its Open+ dialog and answers `fusion_graphics_error` (or `blocked_by_dialog`) with the dialog, instead of sitting at+ `stage: started` for minutes. - Layer arguments take the standard's names: `F.Cu` / `B.Cu` as well as 1 / 16 (`fusion_route_net`, pours). - Net names with `!` (active-low, `!CE`) are accepted by the routing verbs; they were refused as invalid. - Tests: `tests/test_board_model.py` (16 cases over every demo board, against the standard's schemas, copied
rust/bridge/src/display.rsadded+78@@ -0,0 +1,78 @@+//! Fusion's own graphics facts, read from its machine options file: when Fusion has detected an+//! indirect display adapter (DisplayLink and other USB displays) it switches its graphics driver+//! for the whole process, and Fusion Electronics then dies in its D3D12 canvas+//! (SkiaWindowContextDx12, "unrecoverable d3d12") whatever monitor Fusion is on (page issue #121:+//! three crashes with the adapters plugged in, none without). The bridge cannot fix that; it can+//! say so before a tour or an import starts instead of after the crash.+use serde_json::{json, Value};++/// `%APPDATA%/Autodesk/Neutron Platform/Options/NMachineSpecificOptions.xml` (Fusion writes it UTF-16).+fn options_path() -> Option<std::path::PathBuf> {+ std::env::var_os("APPDATA").map(|a| std::path::Path::new(&a).join("Autodesk").join("Neutron Platform").join("Options").join("NMachineSpecificOptions.xml"))+}++fn text_of(bytes: &[u8]) -> String {+ if bytes.starts_with(&[0xFF, 0xFE]) {+ let units: Vec<u16> = bytes[2..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();+ String::from_utf16_lossy(&units)+ } else {+ String::from_utf8_lossy(bytes).into_owned()+ }+}++fn value_of(text: &str, element: &str) -> Option<String> {+ let re = regex::Regex::new(&format!(r#"<{element}\b[^>]*\bValue="([^"]*)""#)).ok()?;+ re.captures(text).map(|c| c[1].to_string())+}++/// The warning for an options file's bytes, or None when Fusion has not detected an indirect adapter.+pub fn parse(bytes: &[u8]) -> Option<Value> {+ let text = text_of(bytes);+ if value_of(&text, "IndirectDisplayAdapterDetected").as_deref() != Some("1") { return None; }+ let driver = value_of(&text, "driverOptionId");+ Some(json!({+ "indirectDisplayAdapter": true,+ "graphicsDriver": driver,+ "risk": "Fusion Electronics may crash with an unrecoverable D3D12 error (SkiaWindowContextDx12), on any monitor",+ "fix": "Unplug the DisplayLink (USB) display adapters and restart Fusion; Fusion re-detects at launch.",+ "source": "Fusion's NMachineSpecificOptions.xml: IndirectDisplayAdapterDetected=1",+ }))+}++/// The warning for this machine, read fresh each call (the file is small and Fusion rewrites it at launch).+pub fn warning() -> Option<Value> {+ options_path().and_then(|p| std::fs::read(p).ok()).and_then(|b| parse(&b))+}++/// Add the warning to a reply (and to its hint) when there is one.+pub fn annotate(mut reply: Value) -> Value {+ if let (Some(w), true) = (warning(), reply.is_object()) {+ let line = "DisplayLink detected: Fusion Electronics may crash with an unrecoverable D3D12 error. Unplug the DisplayLink adapters and restart Fusion before a tour or an Electronics import.";+ reply["_hint"] = json!(format!("{line} {}", reply["_hint"].as_str().unwrap_or("")).trim().to_string());+ reply["displayWarning"] = w;+ }+ reply+}++#[cfg(test)]+mod tests {+ use super::*;+ fn utf16(s: &str) -> Vec<u8> { let mut b = vec![0xFF, 0xFE]; for u in s.encode_utf16() { b.extend_from_slice(&u.to_le_bytes()); } b }++ #[test]+ fn displaylink_box_is_flagged() {+ // the lines from the box in page issue #121+ let xml = r#"<?xml version="1.0" encoding="UTF-16"?><OptionGroups><GraphicsOptionGroup>+<driverOptionId UserName="Graphics driver" Value="VirtualDeviceDx11"/>+<IndirectDisplayAdapterDetected UserName="IndirectDisplayAdapterDetected" Value="1"/></GraphicsOptionGroup></OptionGroups>"#;+ let w = parse(&utf16(xml)).expect("flagged");+ assert_eq!(w["graphicsDriver"], "VirtualDeviceDx11");+ assert!(parse(xml.as_bytes()).is_some(), "UTF-8 too");+ }++ #[test]+ fn ordinary_box_is_not() {+ assert!(parse(&utf16(r#"<OptionGroups><DisplayOptionGroup UserName="Display"></DisplayOptionGroup></OptionGroups>"#)).is_none());+ assert!(parse(&utf16(r#"<IndirectDisplayAdapterDetected UserName="x" Value="0"/>"#)).is_none());+ }+}
rust/bridge/src/main.rs+4−1@@ -42,6 +42,7 @@ mod electronics; mod routing; mod sheet; mod bodies;+mod display; mod fusionpath; mod tour; mod palette;@@ -470,7 +471,7 @@ fn main() { "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]), "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "narrate" => tour::narrate(&parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) }, "demo_control" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::control(&parsed["args"]) } else { demo::demo_control(&parsed["args"]) },- "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION }),+ "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => display::annotate(tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION })), "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) } else { let readiness = || owned_readiness(&**h, &sidecar, &caller); let aps_state = || native_aps_status(&sidecar, &caller)["data"].clone(); let aps_search = |a: &Value| native_aps_search(a); let start = || launch::start(&ctx, &json!({})); let aps_pair = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); let signin_fn = |a: &Value| signin::signin(&ctx, a, &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_pair, aps_signin: &aps_signin }); demo::demo(&ctx, &parsed["args"], &demo::DemoEnv { readiness: &readiness, signin: &signin_fn, aps_state: &aps_state, aps_search: &aps_search }, &pass) }, "web_signin" => signin::web_signin(&ctx, &parsed["args"]), "library_manager" => managed::library_manager(&ctx, &parsed["args"], &pass), "managed_library_create" => managed::create(&ctx, &parsed["args"], &pass), "managed_library_link" => managed::link(&ctx, &parsed["args"], &pass), "managed_library_pull" => managed::pull(&ctx, &parsed["args"], &pass), "managed_library_push" => managed::push(&ctx, &parsed["args"], &pass), "managed_library_unlink" => managed::unlink(&ctx, &parsed["args"], &pass),@@ -1372,6 +1373,8 @@ fn owned_readiness(h: &dyn HostBoth, sidecar: &Arc<Mutex<Option<Sidecar>>>, call let mut r = out.result; if seat_resolved { r["seatDialogAutoResolved"] = json!(true); } r["readinessOwner"] = json!("exe");+ // a DisplayLink box crashes Fusion Electronics: say so on every readiness poll (page issue #121)+ if let Some(w) = display::warning() { r["displayWarning"] = w; } r }
rust/bridge/src/orchestrations.rs+29−4@@ -1232,9 +1232,9 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V v["stage"] = json!(if r["success"].as_bool() == Some(true) { "done" } else { "failed" }); v["finishedAt"] = json!(now_f()); v["result"] = r; eagle_run_write(&sd, &rid, &v); });- return json!({"success": true, "started": true, "runId": run_id, "statusVerb": "fusion_eagle_import_status", "partsExpected": expected,+ return crate::display::annotate(json!({"success": true, "started": true, "runId": run_id, "statusVerb": "fusion_eagle_import_status", "partsExpected": expected, "dialogsDriven": "Fusion's own Open dialogs (sch, then brd), driven in the background by accessible name; Autodesk offers no API for a sch+brd import",- "_hint": format!("Import running in the background (it outlives the relay window). Poll fusion_eagle_import_status {{runId:\"{run_id}\"}} until stage is done or failed; fusion_get_app_state keeps answering meanwhile. The record reports documentName, partsExpected ({expected} elements in the .brd) and partsPlaced from the live board; success needs parts on the board.")});+ "_hint": format!("Import running in the background (it outlives the relay window). Poll fusion_eagle_import_status {{runId:\"{run_id}\"}} until stage is done or failed; fusion_get_app_state keeps answering meanwhile. The record reports documentName, partsExpected ({expected} elements in the .brd) and partsPlaced from the live board; success needs parts on the board.")})); } let sch = args["schPath"].as_str().or(args["filePath"].as_str()).unwrap_or("").replace('\\', "/"); if sch.is_empty() || !sch.to_lowercase().ends_with(".sch") { return json!({"success": false, "error": "schPath (a .sch file on the Windows host) is required.", "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both to Windows (send_files), then call with {schPath, brdPath}."}); }@@ -1253,6 +1253,25 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V "_hint": format!("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + embedded library), call `fusion_open_board {{filePath:\"{brd}\"}}` - it instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the .brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")}); }; use fusion_core::ab::AbClient;+ // Fusion's own crash dialogs (the D3D12 device loss on DisplayLink boxes, the router's+ // "unexpected condition"): an import interrupted by one never finishes, so each wait below+ // checks for them and stops at once, cancelling its Open dialog, instead of running its clock out+ // (page issue #121: the import sat 2.5 min at "started" behind a d3d12 dialog)+ let crash_dialog = || -> Option<Value> {+ crate::lifecycle::dialog_inventory(ctx).into_iter().find(|d| {+ let cat = d["category"].as_str().unwrap_or("");+ let words = format!("{} {}", d["title"].as_str().unwrap_or(""), d["text"].as_str().unwrap_or("")).to_lowercase();+ cat == "fusion_gpu_device_lost" || cat == "fusion_unexpected_condition" || words.contains("d3d12") || words.contains("unexpected condition")+ })+ };+ let crashed = |dlg: Value, open_dialog: Option<i64>, stage: &str, picked: &[String]| -> Value {+ if let Some(h) = open_dialog { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); }+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_crash_dialog", json!({"stage": stage, "dialog": dlg}));+ crate::display::annotate(json!({"success": false, "errorCode": if dlg["category"] == "fusion_gpu_device_lost" { "fusion_graphics_error" } else { "blocked_by_dialog" },+ "stage": stage, "imported": picked, "dialogs": [dlg.clone()], "openDialogCancelled": open_dialog.is_some(),+ "error": format!("Fusion raised '{}' during the import ({stage}); the import cannot finish, so the bridge stopped and cancelled its Open dialog.", dlg["title"].as_str().unwrap_or("a crash dialog")),+ "_hint": format!("{} Save the open documents, restart Fusion (fusion_restart_for_addin {{save:true}}), then run the import again.", dlg["resolution"].as_str().unwrap_or(""))}))+ }; let find_open_dialog = || -> Option<i64> { ab.call("desktop_list_windows", json!({}), &ctx.caller, 10).ok().and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned()).unwrap_or_default().iter().find(|w| w["title"].as_str().unwrap_or("").trim().to_lowercase() == "open").and_then(|w| w["hwnd"].as_i64()) }; let (sch_base, brd_base) = (sch.rsplit('/').next().unwrap_or("").to_string(), brd.rsplit('/').next().unwrap_or("").to_string()); let mut stale_cancelled = false;@@ -1278,7 +1297,10 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V // the queued import runs once Fusion's main thread is free (a new design still settling can // hold it for half a minute), so wait up to 90 s for the first dialog, 30 s for the second // never the dialog just answered: it can linger a moment while the board's dialog replaces it- for _ in 0..(if want == "sch" { 60 } else { 20 }) { std::thread::sleep(Duration::from_millis(1500)); dlg = find_open_dialog().filter(|h| Some(*h) != answered); if dlg.is_some() { break; } }+ for i in 0..(if want == "sch" { 60 } else { 20 }) {+ std::thread::sleep(Duration::from_millis(1500)); dlg = find_open_dialog().filter(|h| Some(*h) != answered); if dlg.is_some() { break; }+ if i % 4 == 3 { if let Some(c) = crash_dialog() { return crashed(c, None, &format!("waiting for the {want} Open dialog"), &picked); } }+ } if dlg.is_some() { restore_minimized(&|t: &str| t.trim().eq_ignore_ascii_case("open")); std::thread::sleep(Duration::from_millis(800)); } let Some(h) = dlg else { if want == "brd" && !picked.is_empty() { break; }@@ -1286,6 +1308,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V }; // the FULL path into "File name:": the dialog opens on whatever folder Fusion used last, so // clicking the bare name only worked when that happened to be the file's folder (2026-10-05)+ if let Some(c) = crash_dialog() { return crashed(c, Some(h), &format!("before typing the {want} path"), &picked); } let full = (if want == "sch" { &sch } else { &brd }).replace('/', "\\"); // role Edit: the bare name also matches the combo box, which has no ValuePattern. A value // set by UIA is not committed by the Open button, so submit (Enter in the field; ab puts@@ -1296,6 +1319,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V // click missed and the dialog stays. Never leave it on the user's screen. let mut gone = false; for _ in 0..8 { std::thread::sleep(Duration::from_secs(1)); if find_open_dialog().map(|x| x != h).unwrap_or(true) { gone = true; break; } }+ if !gone { if let Some(c) = crash_dialog() { return crashed(c, Some(h), &format!("submitting the {want} path"), &picked); } } if !gone { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_dialog_cancelled", json!({"wanted": base, "kind": want}));@@ -1308,8 +1332,9 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V } let mut ready = false; let mut doc_name = String::new();- for _ in 0..40 {+ for i in 0..40 { std::thread::sleep(Duration::from_secs(3));+ if i % 3 == 2 { if let Some(c) = crash_dialog() { return crashed(c, None, "waiting for the imported design", &picked); } } let st = pass("get_app_state", json!({})); let d = &st["data"]; if d["isElectronics"].as_bool() == Some(true) && ["pcb editor", "schematic editor", "board layout", "3d pcb"].contains(&d["activeWorkspace"].as_str().unwrap_or("").to_lowercase().as_str()) { ready = true; doc_name = d["activeDocument"].as_str().unwrap_or("").to_string(); break; }